Beginner Intermediate 22 min read

Chapter 30: Exploratory Data Analysis

Exploratory Data Analysis (EDA) is the detective work that precedes every serious modeling effort. Before you fit a single parameter or write a single SQL query for production, you need to understand the shape, quirks, and stories hiding in your data. EDA is the discipline of doing that systematically — combining numerical summaries, visual inspection, and hypothesis formation into a reproducible investigative process. The term was popularized by John Tukey in his landmark 1977 book, and his core insight remains as true today as it was then: letting the data speak before imposing assumptions is the hallmark of sound analysis.

In practice, EDA serves several overlapping purposes. It surfaces data quality problems — missing values, encoding errors, impossible ranges, duplicate rows — that would silently corrupt downstream models if left unchecked. It reveals the distributional structure of individual variables, exposing skewness, multimodality, and heavy tails that determine which statistical tools are appropriate. It uncovers relationships between variables: correlations that suggest useful features, interactions that motivate more complex models, and groupwise differences that form the basis of actionable business conclusions. Done well, EDA is also a communication tool: the charts and summaries you produce become the evidence base for every claim you make to stakeholders.

This chapter walks through a complete EDA workflow using the tools that practitioners reach for most often: pandas for tabular manipulation, matplotlib and seaborn for Python visualization, and ggplot2 for R. We move from univariate summaries of individual columns, through bivariate relationships, to multivariate views that reveal structure invisible in lower-dimensional slices. Along the way we emphasize a hypothesis-driven mindset: every plot should answer a question you already had, or raise a new one worth pursuing.

Runnable companion notebook for this chapter. Download the Jupyter notebook (.ipynb)

Learning Objectives

  • Compute and interpret descriptive statistics — mean, median, variance, quantiles, and skewness — for both continuous and categorical variables.
  • Produce and read univariate distribution plots (histograms, KDE plots, box plots, bar charts) to characterize individual variables.
  • Identify and investigate outliers using the IQR rule, z-scores, and visualization.
  • Quantify and visualize bivariate relationships with scatter plots, correlation matrices, and grouped summaries.
  • Perform groupby aggregations and construct pivot tables to surface within-group patterns and segment-level differences.
  • Build multivariate views — pair plots, heatmaps, and faceted charts — to detect interactions and confounders.
  • Formulate explicit hypotheses during EDA and use visual and numerical evidence to evaluate them before committing to a modeling approach.

30.1 Getting Oriented: Data Inventory and Summary Statistics Beginner

Getting Oriented: Data Inventory and Summary Statistics

The first ten minutes with any dataset should be the same every time: load it, count things, look at types, and ask a handful of blunt questions. How many rows and columns? What is each column's type — continuous numeric, integer count, nominal category, ordinal, timestamp, or free text? What fraction of values are missing in each column? What does the very first row look like, and the very last?

In pandas, this orientation pass costs almost nothing:

PYTHON
import pandas as pd
import numpy as np

df = pd.read_csv("titanic.csv")

print(df.shape)          # (891, 12)
print(df.dtypes)         # column types
print(df.head())
print(df.isnull().mean().sort_values(ascending=False))

The isnull().mean() call immediately reveals that cabin is missing for 77 % of passengers — a fact that shapes every analysis involving cabin assignment.

Descriptive Statistics for Continuous Variables

For numeric columns, df.describe() returns count, mean, standard deviation, and the five-number summary (min, Q1, median, Q3, max). This is enormously useful but easy to misread. Consider the standard deviation: it is sensitive to outliers in a way the interquartile range (IQR = Q3 − Q1) is not. When mean and median diverge substantially, suspect skewness.

Formal skewness and excess kurtosis help quantify what the eye sees in a histogram:

$$ \text{skewness} = \frac{1}{n} \sum_{i=1}^{n} \left( \frac{x_i - \bar{x}}{s} \right)^3 $$

$$ \text{kurtosis} = \frac{1}{n} \sum_{i=1}^{n} \left( \frac{x_i - \bar{x}}{s} \right)^4 - 3 $$

A right-skewed distribution (long tail to the right) has positive skewness. Excess kurtosis above zero indicates heavier tails than a Gaussian, which matters for outlier sensitivity of mean-based methods.

PYTHON
from scipy import stats

numeric_cols = df.select_dtypes(include="number").columns
for col in numeric_cols:
    s = df[col].dropna()
    print(f"{col:20s}  mean={s.mean():.2f}  median={s.median():.2f}"
          f"  skew={stats.skew(s):.2f}  kurt={stats.kurtosis(s):.2f}")

For the Titanic fare column you will see a mean of roughly $32 but a median near $14, with skewness above 4 — a classic right-skewed count-like variable that benefits from log-transformation before modeling.

Descriptive Statistics for Categorical Variables

For object or categorical columns, df[col].value_counts() and df[col].nunique() are the go-to functions. Always check the cardinality (number of distinct values) early: a column with 850 unique values in a 891-row dataset is probably a near-identifier, not a useful feature.

PYTHON
for col in df.select_dtypes(include=["object", "category"]).columns:
    vc = df[col].value_counts(normalize=True)
    print(f"\n{col}  ({df[col].nunique()} unique)")
    print(vc.head(5).to_string())

Common Pitfalls

  • Reading describe() uncritically. The count row shows how many non-null values exist, not the total rows. A column with a dramatically lower count than the others is missing data silently.
  • Ignoring string columns. df.describe() by default omits object columns unless you pass include='all'. Missing that means missing an entire class of data quality issues.
  • Treating ID columns as features. Columns with monotonically increasing integers or unique strings are not meaningful predictors; they inflate cardinality and can leak row order into models.

Code Examples

Full Data Inventory Report

A reusable function that prints a structured inventory of any DataFrame, covering shape, dtypes, missing rates, and per-column stats.

PYTHON
import pandas as pd
import numpy as np
from scipy import stats

def data_inventory(df: pd.DataFrame) -> pd.DataFrame:
    """Return a per-column summary DataFrame."""
    rows = []
    for col in df.columns:
        s = df[col]
        rec = {
            "column": col,
            "dtype": str(s.dtype),
            "n_missing": s.isna().sum(),
            "pct_missing": round(100 * s.isna().mean(), 1),
            "n_unique": s.nunique(),
        }
        if pd.api.types.is_numeric_dtype(s):
            clean = s.dropna()
            rec["mean"] = round(clean.mean(), 3)
            rec["median"] = round(clean.median(), 3)
            rec["std"] = round(clean.std(), 3)
            rec["skew"] = round(stats.skew(clean), 3)
        else:
            rec["top_value"] = s.value_counts().idxmax() if s.notna().any() else None
        rows.append(rec)
    return pd.DataFrame(rows).set_index("column")

# Example usage
df = pd.read_csv("https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv")
print(data_inventory(df).to_string())
Output
              dtype  n_missing  pct_missing  n_unique     mean   median    std    skew top_value
column
PassengerId   int64          0          0.0       891  446.000  446.000  257.35   0.0       NaN
Survived      int64          0          0.0         2    0.384    0.000    0.49   0.5       NaN
Pclass        int64          0          0.0         3    2.309    3.000    0.84  -0.6       NaN
...

Equivalent Inventory in R

Using base R and dplyr to produce a comparable per-column summary.

R
library(dplyr)

inventory <- function(df) {
  purrr::map_dfr(names(df), function(col) {
    x <- df[[col]]
    tibble(
      column      = col,
      dtype       = class(x)[1],
      n_missing   = sum(is.na(x)),
      pct_missing = round(100 * mean(is.na(x)), 1),
      n_unique    = n_distinct(x),
      mean        = if (is.numeric(x)) round(mean(x, na.rm=TRUE), 3) else NA_real_,
      median      = if (is.numeric(x)) round(median(x, na.rm=TRUE), 3) else NA_real_,
      skew        = if (is.numeric(x)) round(moments::skewness(x, na.rm=TRUE), 3) else NA_real_
    )
  })
}

library(readr)
titanic <- read_csv("https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv")
print(inventory(titanic), n=Inf)

30.2 Univariate Analysis: Understanding Each Variable in Isolation Beginner

Univariate Analysis: Understanding Each Variable in Isolation

Before comparing any two variables, spend time with each one individually. Univariate analysis answers: What values does this column take? How are they distributed? Are there anomalies? The goal is to build a mental model of each feature so that downstream bivariate and multivariate findings can be interpreted correctly.

Continuous Variables: Histograms and KDE Plots

A histogram divides the range of a continuous variable into bins and counts observations per bin. The choice of bin width is critical — too few bins and you miss structure; too many and you see noise. A simple rule is Sturges' rule ($k = \lceil \log_2 n \rceil + 1$), but Freedman-Diaconis ($h = 2 \cdot \text{IQR}(x) \cdot n^{-1/3}$) is more robust to outliers. In practice, overlaying a kernel density estimate (KDE) on the histogram combines the histogram's bin-count accuracy with a smooth density curve:

PYTHON
import matplotlib.pyplot as plt
import seaborn as sns

fig, axes = plt.subplots(1, 2, figsize=(12, 4))

# Histogram + KDE for fare
sns.histplot(df["Fare"].dropna(), kde=True, bins=40, ax=axes[0])
axes[0].set_title("Raw Fare Distribution")
axes[0].set_xlabel("Fare (£)")

# Log-transformed fare looks much more Gaussian
sns.histplot(np.log1p(df["Fare"].dropna()), kde=True, bins=40, ax=axes[1])
axes[1].set_title("Log(1 + Fare) Distribution")
axes[1].set_xlabel("log(1 + Fare)")

plt.tight_layout()
plt.savefig("fare_distribution.png", dpi=150)

The log-transformed version is closer to symmetric — confirming the earlier skewness finding and suggesting log-fare is the better representation for linear models.

Box Plots: Five-Number Summary at a Glance

A box plot encodes the five-number summary visually: the box spans Q1 to Q3 (the IQR), the central line is the median, and the whiskers extend to $Q1 - 1.5 \cdot \text{IQR}$ and $Q3 + 1.5 \cdot \text{IQR}$. Points beyond the whiskers are plotted individually and conventionally flagged as potential outliers — though this is a heuristic, not a verdict.

PYTHON
fig, ax = plt.subplots(figsize=(6, 4))
sns.boxplot(y=df["Age"].dropna(), ax=ax, color="steelblue")
ax.set_title("Age Distribution")
plt.tight_layout()

For age, the box plot reveals a median around 28, mild right skew (whisker longer on top), and a handful of points near 70-80 that the IQR rule flags — but which are plausible ages, not errors.

Categorical Variables: Bar Charts and Frequency Tables

For categorical variables, a bar chart of normalized counts conveys the same information as value<em>counts(normalize=True), but visually:

PYTHON
fig, axes = plt.subplots(1, 2, figsize=(12, 4))

# Passenger class
df["Pclass"].value_counts().sort_index().plot.bar(ax=axes[0], rot=0, color="steelblue")
axes[0].set_title("Passenger Class Counts")
axes[0].set_xlabel("Class")

# Embarked
df["Embarked"].value_counts().plot.bar(ax=axes[1], rot=0, color="coral")
axes[1].set_title("Port of Embarkation")

plt.tight_layout()

Third class is the most common (55 %), and Southampton is overwhelmingly the dominant embarkation port (72 %). Both are facts you need before interpreting any class-stratified or port-stratified finding.

When to Transform

The decision to transform a variable during EDA is about understanding, not cosmetics:

  • Log or log1p for right-skewed positive quantities (income, price, count variables). Compresses the upper tail; makes multiplicative relationships additive.
  • Square root for count data with moderate skew (softer than log).
  • Rank or quantile normalization when the shape is irregular and you want rank-order preserved without distributional assumptions.
  • Standardization (z-score) to compare variables on different scales — but remember it does not change shape, only location and scale.

Always plot both raw and transformed distributions side by side to confirm the transformation achieves its purpose.

Checking for Modes and Multimodality

A unimodal distribution has one peak; a bimodal distribution has two. Bimodality often signals a mixture of subpopulations. In the Titanic age column, a subtle secondary peak near age 1-5 reflects infant passengers — which might warrant separate treatment or a derived is</em>child feature. KDE plots with bandwidth tuning are the best tool for detecting modes without binning artifacts:

PYTHON
fig, ax = plt.subplots(figsize=(8, 4))
for bw in [0.5, 1.0, 2.0]:
    df["Age"].dropna().plot.kde(bw_method=bw, ax=ax, label=f"bw={bw}")
ax.legend()
ax.set_title("Age KDE with Different Bandwidths")

Larger bandwidth smooths away the infant peak; smaller bandwidth preserves it but introduces noise. The right bandwidth sits where structure stabilizes across a range of values.

Code Examples

Multi-Panel Univariate Overview for All Numeric Columns

Generates a grid of histograms for every numeric column in one call — useful as a fast first-look script.

PYTHON
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

df = pd.read_csv("https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv")
numeric_cols = df.select_dtypes(include="number").columns.tolist()

n = len(numeric_cols)
ncols = 3
nrows = (n + ncols - 1) // ncols

fig, axes = plt.subplots(nrows, ncols, figsize=(5 * ncols, 4 * nrows))
axes = axes.flatten()

for i, col in enumerate(numeric_cols):
    data = df[col].dropna()
    sns.histplot(data, kde=True, ax=axes[i], bins="fd", color="steelblue")
    axes[i].set_title(f"{col}  (skew={data.skew():.2f})")
    axes[i].set_xlabel("")

for j in range(i + 1, len(axes)):
    axes[j].set_visible(False)

plt.suptitle("Univariate Distributions", fontsize=14, y=1.02)
plt.tight_layout()
plt.savefig("univariate_overview.png", dpi=150, bbox_inches="tight")
print("Saved univariate_overview.png")
Output
Saved univariate_overview.png

Univariate Plots with ggplot2

Faceted histograms for all numeric columns in ggplot2 using tidyr::pivot_longer.

R
library(tidyverse)

titanic <- read_csv("https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv")

numeric_long <- titanic |>
  select(where(is.numeric)) |>
  pivot_longer(everything(), names_to = "variable", values_to = "value") |>
  drop_na()

ggplot(numeric_long, aes(x = value)) +
  geom_histogram(aes(y = after_stat(density)), bins = 30,
                 fill = "steelblue", color = "white", alpha = 0.8) +
  geom_density(color = "firebrick", linewidth = 0.8) +
  facet_wrap(~variable, scales = "free") +
  labs(title = "Univariate Distributions", x = NULL, y = "Density") +
  theme_minimal(base_size = 12)

ggsave("univariate_ggplot.png", width = 12, height = 8, dpi = 150)

30.3 Outlier Detection: Finding and Handling Anomalous Values Intermediate

Outlier Detection: Finding and Handling Anomalous Values

Outliers are data points that lie far from the bulk of the distribution. They matter for multiple reasons: they can represent genuine extreme events (a whale customer, a record-breaking measurement), data entry errors (age = 999, price = -1), or the natural heavy-tail behavior of a phenomenon. EDA cannot decide which kind an outlier is — that requires domain knowledge — but it can find them systematically and present them for scrutiny.

The IQR Rule

The most widely used rule flags a value as a potential outlier if it lies more than 1.5 × IQR below Q1 or above Q3. This was proposed by Tukey (1977) and has the property that for a Gaussian distribution it catches roughly 0.7 % of observations — rare enough to be worth investigating.

$$ \text{lower fence} = Q_1 - 1.5 \cdot \text{IQR} $$
$$ \text{upper fence} = Q_3 + 1.5 \cdot \text{IQR} $$

PYTHON
def iqr_outliers(series: pd.Series) -> pd.Series:
    """Return boolean mask — True where IQR outlier."""
    q1, q3 = series.quantile([0.25, 0.75])
    iqr = q3 - q1
    return (series < q1 - 1.5 * iqr) | (series > q3 + 1.5 * iqr)

for col in ["Age", "Fare", "SibSp", "Parch"]:
    mask = iqr_outliers(df[col].dropna())
    pct = 100 * mask.mean()
    print(f"{col:10s}: {mask.sum():4d} outliers ({pct:.1f}%)")

For Fare, you will see roughly 17 % flagged — evidence that this rule's threshold is too aggressive for a highly skewed variable. Always interpret the percentage in light of the distribution shape; the IQR rule is designed for roughly symmetric data.

Z-Score Method

The z-score measures how many standard deviations a value is from the mean:

$$ z_i = \frac{x_i - \bar{x}}{s} $$

A common threshold is $|z| > 3$, catching about 0.3 % of a Gaussian distribution.

PYTHON
from scipy.stats import zscore

df["fare_z"] = zscore(df["Fare"].fillna(df["Fare"].median()))
outlier_rows = df[df["fare_z"].abs() > 3][["Name", "Fare", "Pclass", "fare_z"]]
print(outlier_rows.sort_values("fare_z", ascending=False).head(10))

The extreme-fare passengers are predominantly first-class travelers, with fares above £500 — this is a real phenomenon (expensive suites), not an error.

Modified Z-Score (Robust to Skewness)

Both the IQR rule and z-score can be misleading for skewed distributions because the mean and standard deviation are themselves pulled by the outliers. A more robust alternative uses the median absolute deviation (MAD):

$$ \text{MAD} = \text{median}(|x_i - \tilde{x}|) $$

$$ M_i = \frac{0.6745 \cdot (x_i - \tilde{x})}{\text{MAD}} $$

Values with $|M_i| > 3.5$ are flagged. The constant 0.6745 makes MAD consistent with the standard deviation under normality.

PYTHON
def modified_zscore(series: pd.Series) -> pd.Series:
    med = series.median()
    mad = (series - med).abs().median()
    return 0.6745 * (series - med) / (mad + 1e-10)  # avoid div-by-zero

df["fare_mz"] = modified_zscore(df["Fare"].dropna())
print((df["fare_mz"].abs() > 3.5).sum(), "outliers via modified z-score")

Visualization: Box Plots and Strip Plots

Numerical flags are only the start — visualization reveals context. A strip plot (jittered scatter) overlaid on a box plot shows every point, distinguishing the near-outliers from the extreme ones:

PYTHON
fig, ax = plt.subplots(figsize=(8, 5))
sns.boxplot(x="Pclass", y="Fare", data=df, ax=ax,
            flierprops=dict(marker="o", color="red", markersize=4))
sns.stripplot(x="Pclass", y="Fare", data=df, ax=ax,
              alpha=0.3, jitter=True, color="steelblue", size=3)
ax.set_title("Fare by Passenger Class with Outliers Highlighted")
plt.tight_layout()

This immediately shows that high fares are almost exclusively in first class, and that the outliers cluster there rather than being scattered randomly — pointing to suite pricing rather than data error.

What to Do With Outliers

Once identified, outliers require a decision:

  • Investigate first. Look up the rows. Does the value make sense in context?
  • Domain clamp. If you know the valid range (e.g., age must be 0–120), values outside that range are errors to be corrected or nullified.
  • Winsorize. Replace values above the 99th percentile with the 99th percentile value. This preserves the row while limiting influence. Use scipy.stats.mstats.winsorize.
  • Transform. Log-transforming a right-skewed variable often makes former outliers look ordinary by compressing the upper tail.
  • Separate stratum. Sometimes outliers form their own legitimate segment (e.g., enterprise customers) that deserves its own model.
  • Never silently delete without documenting what was removed and why.

Code Examples

Outlier Summary Table Using Multiple Methods

Compares IQR, z-score, and modified z-score outlier counts side by side across all numeric columns.

PYTHON
import pandas as pd
import numpy as np
from scipy import stats

df = pd.read_csv("https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv")

def outlier_summary(df: pd.DataFrame) -> pd.DataFrame:
    rows = []
    for col in df.select_dtypes(include="number").columns:
        s = df[col].dropna()
        n = len(s)

        # IQR
        q1, q3 = s.quantile([0.25, 0.75])
        iqr = q3 - q1
        n_iqr = int(((s < q1 - 1.5*iqr) | (s > q3 + 1.5*iqr)).sum())

        # z-score
        z = np.abs(stats.zscore(s))
        n_z = int((z > 3).sum())

        # Modified z-score (MAD-based)
        med = s.median()
        mad = (s - med).abs().median()
        mz = 0.6745 * (s - med) / (mad + 1e-10)
        n_mz = int((mz.abs() > 3.5).sum())

        rows.append({
            "column": col, "n": n,
            "IQR_outliers": n_iqr, "IQR_pct": round(100*n_iqr/n, 1),
            "zscore_outliers": n_z, "zscore_pct": round(100*n_z/n, 1),
            "mad_outliers": n_mz, "mad_pct": round(100*n_mz/n, 1),
        })
    return pd.DataFrame(rows).set_index("column")

print(outlier_summary(df).to_string())
Output
             n  IQR_outliers  IQR_pct  zscore_outliers  zscore_pct  mad_outliers  mad_pct
column
PassengerId  891            0      0.0                0         0.0             0      0.0
Survived     891            0      0.0                0         0.0             0      0.0
Pclass       891            0      0.0                0         0.0             0      0.0
Age          714           11      1.5                1         0.1             9      1.3
SibSp        891           46      5.2                7         0.8            46      5.2
Parch        891           213    23.9                5         0.6            85      9.5
Fare         891           116    13.0               20         2.2            91     10.2

Winsorization Example

Demonstrates scipy winsorize and shows before/after statistics.

PYTHON
import pandas as pd
import numpy as np
from scipy.stats.mstats import winsorize
import matplotlib.pyplot as plt

df = pd.read_csv("https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv")

fare = df["Fare"].dropna()
fare_winsorized = winsorize(fare, limits=[0.01, 0.01])  # clip bottom and top 1%

print(f"Original  — mean: {fare.mean():.2f}, std: {fare.std():.2f}, max: {fare.max():.2f}")
print(f"Winsorized — mean: {fare_winsorized.mean():.2f}, std: {fare_winsorized.std():.2f}, max: {fare_winsorized.max():.2f}")

fig, axes = plt.subplots(1, 2, figsize=(12, 4), sharey=False)
axes[0].hist(fare, bins=40, color="steelblue")
axes[0].set_title("Original Fare")
axes[1].hist(np.array(fare_winsorized), bins=40, color="coral")
axes[1].set_title("Winsorized Fare (1%–99%)")
plt.tight_layout()
plt.savefig("winsorized_fare.png", dpi=150)
Output
Original  — mean: 32.20, std: 49.69, max: 512.33
Winsorized — mean: 29.74, std: 41.06, max: 263.00

30.4 Bivariate Analysis: Relationships Between Two Variables Intermediate

Bivariate Analysis: Relationships Between Two Variables

Once you understand each variable in isolation, the investigative question shifts: how do two variables relate? The answer depends on the types of the two variables.

Continuous vs. Continuous: Scatter Plots and Correlation

A scatter plot is the primary tool for two continuous variables. You are looking for:

  • Direction: does one variable increase as the other increases (positive), decrease (negative), or neither?
  • Strength: how tightly do points cluster around the trend line?
  • Form: is the relationship linear, curved, or non-existent?
  • Outliers: are there points far from the cloud that may be driving any apparent relationship?

PYTHON
fig, ax = plt.subplots(figsize=(7, 5))
sns.scatterplot(x="Age", y="Fare", hue="Survived", data=df,
                alpha=0.6, palette={0: "red", 1: "steelblue"}, ax=ax)
sns.regplot(x="Age", y="Fare", data=df, scatter=False,
            color="gray", line_kws={"linestyle": "--"}, ax=ax)
ax.set_title("Age vs. Fare, colored by Survival")

The regression line (from regplot) shows any linear trend; coloring by survival lets you check whether the relationship differs between groups — the beginning of multivariate thinking.

Pearson's correlation coefficient $r$ quantifies linear association on a $[-1, 1]$ scale:

$$ r = \frac{\sum_{i=1}^n (x_i - \bar{x})(y_i - \bar{y})}{\sqrt{\sum (x_i-\bar{x})^2 \sum (y_i-\bar{y})^2}} $$

$r = 0$ does not mean no relationship; a U-shaped relationship can have $r \approx 0$. Spearman's rank correlation $\rho$ is more appropriate when the relationship is monotone but not linear, or when outliers are present:

PYTHON
from scipy.stats import pearsonr, spearmanr

clean = df[["Age", "Fare"]].dropna()
r_p, p_p = pearsonr(clean["Age"], clean["Fare"])
r_s, p_s = spearmanr(clean["Age"], clean["Fare"])
print(f"Pearson  r={r_p:.3f}  p={p_p:.4f}")
print(f"Spearman r={r_s:.3f}  p={p_s:.4f}")

Correlation Matrices and Heatmaps

When you have more than two numeric variables, computing all pairwise correlations at once reveals which pairs deserve deeper investigation:

PYTHON
import seaborn as sns

corr = df[["Survived", "Pclass", "Age", "SibSp", "Parch", "Fare"]].corr(method="spearman")

fig, ax = plt.subplots(figsize=(7, 6))
sns.heatmap(corr, annot=True, fmt=".2f", cmap="coolwarm",
            center=0, square=True, ax=ax)
ax.set_title("Spearman Correlation Matrix")
plt.tight_layout()

The heatmap immediately surfaces: Survived correlates negatively with Pclass (higher class = lower number = higher survival) and positively with Fare — and those two correlations are themselves large because fare and class are collinear.

Continuous vs. Categorical: Box Plots and Violin Plots

When one variable is categorical and the other continuous, the question is: does the distribution of the continuous variable differ across groups?

PYTHON
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Box plot: median and spread by survival
sns.boxplot(x="Survived", y="Fare", data=df, palette="Set2", ax=axes[0])
axes[0].set_xticklabels(["Died", "Survived"])
axes[0].set_title("Fare by Survival Status")

# Violin plot: full distribution shape
sns.violinplot(x="Pclass", y="Age", data=df, palette="Set3",
               inner="quartile", ax=axes[1])
axes[1].set_title("Age Distribution by Passenger Class")

plt.tight_layout()

The violin plot reveals that third-class passengers tend to be slightly younger than first-class passengers, with a more irregular age distribution — including that infant peak.

Categorical vs. Categorical: Crosstabs and Grouped Bar Charts

For two categorical variables, a crosstab (contingency table) reveals co-occurrence counts, and a normalized version reveals conditional probabilities:

PYTHON
# Survival rate by sex
crosstab = pd.crosstab(df["Sex"], df["Survived"], normalize="index")
print(crosstab.round(3))

# Visual
crosstab.plot.bar(rot=0, figsize=(6, 4),
                  color=["salmon", "steelblue"],
                  title="Survival Rate by Sex")
plt.ylabel("Proportion")
plt.legend(["Died", "Survived"])
plt.tight_layout()

The output shows female survival at ~74 % versus male survival at ~19 % — one of the strongest single-variable signals in the dataset.

Testing for Association

Beyond visualizing, you can formally test whether an association is statistically significant. For categorical-categorical relationships, the chi-squared test of independence applies:

$$ \chi^2 = \sum_{i,j} \frac{(O_{ij} - E_{ij})^2}{E_{ij}} $$

PYTHON
from scipy.stats import chi2_contingency

contingency = pd.crosstab(df["Sex"], df["Survived"])
chi2, p, dof, expected = chi2_contingency(contingency)
print(f"chi2={chi2:.2f}, p={p:.2e}, dof={dof}")

A p-value near zero confirms what the chart shows: sex and survival are not independent. But remember, statistical significance does not equal causal importance — it means the association is unlikely to be sampling noise.

Code Examples

Annotated Scatter Matrix (Pair Plot)

Uses seaborn pairplot to visualize all pairwise continuous relationships in one call, with survival as the hue.

PYTHON
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

df = pd.read_csv("https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv")

cols = ["Age", "Fare", "SibSp", "Parch", "Survived"]
subset = df[cols].dropna()
subset["Survived"] = subset["Survived"].map({0: "Died", 1: "Survived"})

g = sns.pairplot(
    subset,
    hue="Survived",
    palette={"Died": "salmon", "Survived": "steelblue"},
    diag_kind="kde",
    plot_kws={"alpha": 0.4, "s": 20},
    corner=True,
)
g.figure.suptitle("Pair Plot: Titanic Numeric Features by Survival",
                  y=1.02, fontsize=13)
plt.savefig("pairplot.png", dpi=150, bbox_inches="tight")
print("Saved pairplot.png")
Output
Saved pairplot.png

Bivariate Analysis with ggplot2: Survival Rate by Class and Sex

Faceted bar chart showing conditional survival rates, with stat_summary for error bars.

R
library(tidyverse)

titanic <- read_csv("https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv") |>
  mutate(
    Pclass = factor(Pclass, labels = c("1st", "2nd", "3rd")),
    Sex    = factor(Sex)
  )

survival_summary <- titanic |>
  group_by(Pclass, Sex) |>
  summarise(
    n          = n(),
    survival   = mean(Survived, na.rm = TRUE),
    se         = sqrt(survival * (1 - survival) / n),
    .groups = "drop"
  )

ggplot(survival_summary, aes(x = Pclass, y = survival, fill = Sex)) +
  geom_col(position = "dodge", alpha = 0.85) +
  geom_errorbar(
    aes(ymin = survival - 1.96*se, ymax = survival + 1.96*se),
    position = position_dodge(0.9), width = 0.2
  ) +
  scale_y_continuous(labels = scales::percent_format()) +
  scale_fill_manual(values = c("female" = "steelblue", "male" = "salmon")) +
  labs(
    title = "Titanic Survival Rate by Class and Sex",
    x = "Passenger Class", y = "Survival Rate"
  ) +
  theme_minimal(base_size = 13)

ggsave("survival_by_class_sex.png", width = 8, height = 5, dpi = 150)

30.5 Groupby, Aggregation, and Pivot Tables Intermediate

Groupby, Aggregation, and Pivot Tables

Some of the most actionable insights in EDA come not from individual observations but from groups of them. Groupby-aggregation answers questions like: What is the average fare for each class? Does survival rate differ by embarkation port? How does age distribution change across decade-of-birth cohorts? These segmented summaries are the analytical backbone of business intelligence and the foundation of feature engineering.

The Split-Apply-Combine Pattern

The pandas groupby API implements the split-apply-combine paradigm:

  1. Split the DataFrame into subgroups based on one or more keys.
  2. Apply an aggregation, transformation, or filter to each group.
  3. Combine the results back into a new DataFrame.

PYTHON
# Single-key aggregation
survival_by_class = (
    df.groupby("Pclass")["Survived"]
    .agg(["mean", "count", "sum"])
    .rename(columns={"mean": "survival_rate", "count": "n", "sum": "n_survived"})
    .round(3)
)
print(survival_by_class)

This confirms the expected pattern: first-class survival is 63 %, second-class 47 %, third-class 24 %.

Multiple Aggregations with Named Aggregation

Pandas' named aggregation syntax (agg(col=(column, func))) allows combining different statistics for different columns cleanly:

PYTHON
group_stats = (
    df.groupby(["Pclass", "Sex"])
    .agg(
        n                = ("PassengerId", "count"),
        survival_rate    = ("Survived",    "mean"),
        median_age       = ("Age",         "median"),
        median_fare      = ("Fare",        "median"),
        fare_p90         = ("Fare",        lambda x: x.quantile(0.9)),
    )
    .reset_index()
    .round(2)
)
print(group_stats.to_string(index=False))

The result is a compact segment profile: third-class males have a survival rate of 14 %, a median age of 25, and a median fare of £8. First-class females have a survival rate of 97 %.

Pivot Tables

A pivot table is a two-dimensional crosstab that summarizes a numeric value as a function of two categorical keys — the row key and the column key. In pandas:

PYTHON
pivot = df.pivot_table(
    values="Survived",
    index="Pclass",
    columns="Sex",
    aggfunc="mean",
    margins=True,
    margins_name="Overall",
).round(3)
print(pivot)

The output is a 4×3 table (three classes plus overall row, two sexes plus overall column) of survival rates. The margins=True argument appends row and column totals — here, marginal survival rates.

Pivot tables work equally well for non-binary outcomes:

PYTHON
fare_pivot = df.pivot_table(
    values="Fare",
    index="Pclass",
    columns="Embarked",
    aggfunc="median",
    margins=True,
).round(1)
print(fare_pivot)

Custom Aggregation Functions

When built-in aggregation functions are insufficient, pass a callable:

PYTHON
def coef_of_variation(x):
    """Standard deviation divided by mean — relative variability."""
    return x.std() / x.mean() if x.mean() != 0 else np.nan

df.groupby("Pclass")["Fare"].agg(coef_of_variation).rename("CV_fare")

Coefficient of variation reveals that fare variability is highest in first class (CV ≈ 0.85) — evidence that first class had a range of cabin types with very different prices.

Visualizing Group Comparisons

Aggregate summaries become most persuasive when plotted:

PYTHON
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Survival rate by class and sex (grouped bar)
group_stats.pivot(index="Pclass", columns="Sex", values="survival_rate").plot.bar(
    ax=axes[0], rot=0, color=["steelblue", "salmon"]
)
axes[0].set_title("Survival Rate by Class and Sex")
axes[0].set_ylabel("Rate")
axes[0].legend(title="Sex")

# Median fare by class and sex (grouped bar)
group_stats.pivot(index="Pclass", columns="Sex", values="median_fare").plot.bar(
    ax=axes[1], rot=0, color=["steelblue", "salmon"]
)
axes[1].set_title("Median Fare by Class and Sex")
axes[1].set_ylabel("Fare (£)")

plt.tight_layout()

Rolling and Expanding Aggregations for Time Series

Groupby is not limited to categorical splits. When your data has a time dimension, rolling windows reveal trends:

PYTHON
# Hypothetical: daily sales DataFrame
# sales_df["revenue"].rolling(window=7, min_periods=1).mean()  # 7-day moving average
# sales_df["revenue"].expanding().mean()                       # cumulative mean

These techniques apply the same split-apply-combine logic over time windows rather than discrete categories.

Pitfalls

  • Simpson's paradox. A trend observed in grouped data can reverse when the groups are combined. Always check both disaggregated and aggregate views. The Titanic is a textbook example: overall, passengers in third class had lower fares AND lower survival, but within each sex the class gradient is consistent.
  • Unequal group sizes. A mean of 100 % survival from a group of 2 is less meaningful than 90 % from a group of 100. Always include group counts in your aggregation output.
  • Multiple groupby keys and memory. Grouping on high-cardinality keys produces many small groups; aggregation results can explode in size. Check df.groupby(keys).ngroups before aggregating on unknown cardinality.

Code Examples

Segment Heatmap from Pivot Table

Creates an annotated heatmap from a pivot table of survival rates, making group differences visually immediate.

PYTHON
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

df = pd.read_csv("https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv")

# Three-way pivot: class × sex, values = survival rate
pivot = df.pivot_table(
    values="Survived",
    index="Pclass",
    columns="Sex",
    aggfunc="mean",
)

fig, ax = plt.subplots(figsize=(6, 4))
sns.heatmap(
    pivot,
    annot=True,
    fmt=".1%",
    cmap="RdYlGn",
    vmin=0, vmax=1,
    linewidths=0.5,
    ax=ax,
)
ax.set_title("Survival Rate by Class and Sex")
ax.set_xlabel("Sex")
ax.set_ylabel("Passenger Class")
plt.tight_layout()
plt.savefig("survival_heatmap.png", dpi=150)
print("Saved survival_heatmap.png")
Output
Saved survival_heatmap.png

dplyr Group Summary with Confidence Intervals

Uses dplyr to compute group means, standard errors, and 95% CIs — directly ready for plotting.

R
library(tidyverse)

titanic <- read_csv("https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv") |>
  mutate(Pclass = factor(Pclass, labels = c("1st","2nd","3rd")))

group_summary <- titanic |>
  group_by(Pclass, Sex) |>
  summarise(
    n          = n(),
    survival   = mean(Survived, na.rm = TRUE),
    se         = sd(Survived, na.rm = TRUE) / sqrt(n()),
    ci_lo      = survival - 1.96 * se,
    ci_hi      = survival + 1.96 * se,
    median_age = median(Age, na.rm = TRUE),
    .groups    = "drop"
  )

print(group_summary, n = Inf)

ggplot(group_summary, aes(x = Pclass, y = survival,
                           color = Sex, group = Sex)) +
  geom_line(linewidth = 1) +
  geom_point(aes(size = n)) +
  geom_ribbon(aes(ymin = ci_lo, ymax = ci_hi, fill = Sex),
              alpha = 0.15, color = NA) +
  scale_y_continuous(labels = scales::percent_format()) +
  labs(title = "Survival Rate by Class and Sex with 95% CI",
       x = "Class", y = "Survival Rate", size = "n") +
  theme_minimal(base_size = 12)

ggsave("survival_ci_ggplot.png", width = 8, height = 5, dpi = 150)

30.6 Multivariate Analysis and Hypothesis-Driven EDA Intermediate

Multivariate Analysis and Hypothesis-Driven EDA

Univariate and bivariate analyses reveal individual variables and pairwise relationships. But real datasets contain interactions: the effect of variable A on outcome Y may depend on the level of variable B. Multivariate EDA explores these higher-order structures while maintaining the discipline of asking explicit questions and checking them against evidence.

The Hypothesis-Driven Mindset

Effective EDA is not aimless chart generation — it is a cycle of hypothesize → visualize → quantify → refine. Before each plot, write down the question it answers. After each plot, decide: does the evidence support the hypothesis, refute it, or suggest a new one? This discipline keeps your analysis focused and produces a narrative that stakeholders can follow.

For the Titanic, a natural initial hypothesis is:

H1: Passenger class determines survival, primarily through access to lifeboats.

We already know marginal survival rates by class. The next question is whether class explains the sex effect, or whether both sex and class have independent effects.

Faceted Plots: Conditioning on a Third Variable

Faceted plots (also called small multiples) condition the bivariate relationship on a third categorical variable, making interactions visible. In seaborn, FacetGrid or the col/row parameters of most plot functions handle this:

PYTHON
g = sns.FacetGrid(df.dropna(subset=["Age"]),
                  col="Pclass", row="Survived",
                  height=3, aspect=1.2,
                  margin_titles=True)
g.map(sns.histplot, "Age", bins=20, kde=True, color="steelblue")
g.set_axis_labels("Age", "Count")
g.set_titles(row_template="Survived={row_name}",
             col_template="Class {col_name}")
g.figure.suptitle("Age Distribution by Class and Survival", y=1.02)
plt.savefig("faceted_age.png", dpi=150, bbox_inches="tight")

With 6 panels (3 classes × 2 survival outcomes), you can now see: among third-class passengers who died, the distribution peaks in the 20s. Among those who survived, the infant peak is more prominent — consistent with a "children first" policy applied unevenly across classes.

ggplot2: Facets and Layers in R

ggplot2's grammar of graphics makes multivariate layering especially expressive. The facet<em>grid and facet</em>wrap functions apply the same faceting logic:

R
library(tidyverse)

titanic <- read_csv("https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv") |>
  mutate(
    Survived = factor(Survived, labels = c("Died", "Survived")),
    Pclass   = factor(Pclass, labels = c("1st","2nd","3rd"))
  )

ggplot(titanic |> drop_na(Age), aes(x = Age, fill = Sex)) +
  geom_histogram(aes(y = after_stat(density)), bins = 20,
                 alpha = 0.6, position = "identity") +
  geom_density(aes(color = Sex), linewidth = 0.8, fill = NA) +
  facet_grid(Survived ~ Pclass) +
  scale_fill_manual(values  = c("female"="steelblue", "male"="salmon")) +
  scale_color_manual(values = c("female"="steelblue", "male"="salmon")) +
  labs(title = "Age by Sex, Class, and Survival Outcome",
       x = "Age", y = "Density") +
  theme_minimal(base_size = 11)

This six-panel plot encodes four variables simultaneously: age (x), density (y), sex (color), class (column), survival (row).

Pair Plots with a Grouping Variable

A pair plot (scatter matrix) shows all pairwise scatter plots and univariate distributions together. The diagonal typically shows KDE or histogram; the off-diagonal shows scatter plots:

PYTHON
cols = ["Age", "Fare", "Pclass", "SibSp", "Survived"]
subset = df[cols].dropna().copy()
subset["Survived"] = subset["Survived"].map({0: "Died", 1: "Survived"})

g = sns.pairplot(subset, hue="Survived",
                 palette={"Died": "salmon", "Survived": "steelblue"},
                 diag_kind="kde", corner=True,
                 plot_kws={"alpha": 0.35, "s": 15})
g.figure.suptitle("Pair Plot by Survival", y=1.02)

Correlation Does Not Imply Causation — and Neither Does Any EDA

EDA is generative, not confirmatory. Every finding is a hypothesis for further investigation, not a conclusion. The strong association between Sex == female and Survived == 1 does not by itself prove that being female caused survival — it is consistent with a lifeboat-loading policy, but also with wealth differences (women more concentrated in first class) or other confounders. Testing causality requires experimental design or causal inference methods that lie beyond EDA.

Building the EDA Narrative

By the end of a thorough EDA, you should be able to write a short memo with:

  • Data quality findings: missing value patterns, potential errors, outliers requiring treatment.
  • Distributional summaries: which variables are skewed, which have outliers, which need transformation.
  • Key relationships: the strongest predictors of the outcome variable, notable interactions, surprising null findings.
  • Hypotheses carried forward: questions that EDA raised but cannot answer — e.g., whether class and sex interact in a statistically significant way, which requires a formal model.

This memo becomes the justification for every preprocessing and feature engineering decision you make next.

Code Examples

Interaction Plot: Mean Survival by Class, Sex, and Embarkation Port

Demonstrates a three-way interaction by plotting mean survival as lines across classes, faceted by embarkation port.

PYTHON
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

df = pd.read_csv("https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv")

# Compute mean survival for each combination of Pclass, Sex, Embarked
agg = (
    df.dropna(subset=["Embarked"])
    .groupby(["Embarked", "Pclass", "Sex"])["Survived"]
    .agg(["mean", "count"])
    .reset_index()
    .rename(columns={"mean": "Survival_Rate", "count": "n"})
)
agg["Pclass"] = agg["Pclass"].astype(str)

g = sns.FacetGrid(agg, col="Embarked", height=4, aspect=1.1,
                  col_order=["S", "C", "Q"])
g.map_dataframe(
    sns.pointplot,
    x="Pclass", y="Survival_Rate",
    hue="Sex",
    palette={"female": "steelblue", "male": "salmon"},
    dodge=True, markers=["o", "s"], linestyles=["-", "--"]
)
g.add_legend(title="Sex")
g.set_axis_labels("Passenger Class", "Mean Survival Rate")
g.set_titles("Embarked: {col_name}")
g.figure.suptitle("Interaction: Class × Sex × Embarkation", y=1.03)
plt.savefig("interaction_plot.png", dpi=150, bbox_inches="tight")
print("Saved interaction_plot.png")
Output
Saved interaction_plot.png

Correlation Change by Subgroup: Checking for Simpson's Paradox

Computes Pearson r between Fare and Pclass both overall and within each sex to surface potential confounding.

PYTHON
import pandas as pd
import numpy as np
from scipy.stats import pearsonr

df = pd.read_csv("https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv")
clean = df[["Fare", "Pclass", "Sex", "Survived"]].dropna()

print("=== Correlation: Survived ~ Fare ===")
r_all, _ = pearsonr(clean["Survived"], clean["Fare"])
print(f"  Overall:   r = {r_all:.3f}")
for sex, grp in clean.groupby("Sex"):
    r, _ = pearsonr(grp["Survived"], grp["Fare"])
    print(f"  {sex:8s}: r = {r:.3f}  (n={len(grp)})")

print("\n=== Correlation: Survived ~ Pclass ===")
r_all, _ = pearsonr(clean["Survived"], clean["Pclass"])
print(f"  Overall:   r = {r_all:.3f}")
for sex, grp in clean.groupby("Sex"):
    r, _ = pearsonr(grp["Survived"], grp["Pclass"])
    print(f"  {sex:8s}: r = {r:.3f}  (n={len(grp)})")
Output
=== Correlation: Survived ~ Fare ===
  Overall:   r = 0.257
  female  : r = 0.179  (n=314)
  male    : r = 0.218  (n=577)

=== Correlation: Survived ~ Pclass ===
  Overall:   r = -0.338
  female  : r = -0.329  (n=314)
  male    : r = -0.290  (n=577)

GGally Pair Plot in R

Uses GGally::ggpairs for a publication-quality scatter matrix with correlation coefficients and survival coloring.

R
library(tidyverse)
library(GGally)

titanic <- read_csv("https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv") |>
  select(Survived, Pclass, Age, Fare, SibSp) |>
  drop_na() |>
  mutate(Survived = factor(Survived, labels = c("Died", "Survived")))

ggpairs(
  titanic,
  aes(color = Survived, alpha = 0.5),
  columns = c("Pclass", "Age", "Fare", "SibSp"),
  upper = list(continuous = wrap("cor", method = "spearman", size = 3)),
  lower = list(continuous = wrap("points", size = 0.8)),
  diag  = list(continuous = wrap("densityDiag"))
) +
  scale_color_manual(values = c("Died"="salmon", "Survived"="steelblue")) +
  scale_fill_manual( values = c("Died"="salmon", "Survived"="steelblue")) +
  labs(title = "GGally Pair Plot: Titanic") +
  theme_minimal(base_size = 10)

ggsave("ggally_pairs.png", width = 10, height = 9, dpi = 150)