🎯 What You'll Learn

  • Why exploratory visualization is an essential step before building any ML model
  • Create line charts, bar charts, scatter plots, and histograms with matplotlib.pyplot
  • Use Seaborn's statistical plot functions: histplot, kdeplot, boxplot, violinplot, heatmap, pairplot
  • Read and interpret box plots, including the five-number summary and outlier detection
  • Build multi-panel EDA dashboards using plt.subplots() and correlation heatmaps
⚙️
Setup

Install both libraries once: pip install matplotlib seaborn. Every visualization lesson starts with import matplotlib.pyplot as plt and import seaborn as sns. In Jupyter notebooks, add %matplotlib inline so charts render inline.

1 Why Visualization Comes Before Modeling

Experienced data scientists share a common rule: never touch a model until you've visualized the data. The reason is simple — numbers in a table can hide patterns that a chart makes immediately obvious. The famous Anscombe's Quartet demonstrates this powerfully: four datasets with nearly identical means, variances, and correlations that look completely different when plotted.

Visualization serves several critical purposes in an ML workflow:

  • Distribution check: Are a feature's values spread symmetrically in a bell shape, piled up on one side (skewed), or split into two humps (bimodal)? This determines how you should rescale the feature. (Lessons 06–07 give these shapes their proper names and math.)
  • Outlier detection: Box plots instantly reveal extreme values that could distort model training.
  • Relationship discovery: Scatter plots and heatmaps show which features correlate with the target and with each other.
  • Outcome balance: A bar chart of target values tells you instantly whether one outcome is far rarer than the other — a situation that needs special care when modeling (Phase 2 explains why).
  • Data quality: Unexpected spikes, gaps, or flat lines in a plot often indicate data collection errors.

Matplotlib vs Seaborn

Matplotlib is the foundational plotting library — it gives you precise control over every element of a figure. Everything in the Python visualization ecosystem ultimately renders through Matplotlib.

Seaborn is built on top of Matplotlib and provides a higher-level interface designed specifically for statistical data visualization. A single Seaborn function call produces a publication-quality chart that would take 20 lines of raw Matplotlib code. The trade-off is less fine-grained control. In practice, you'll use Seaborn for statistical exploration and Matplotlib for custom or publication plots.

🔑
Seaborn Works with Pandas DataFrames Natively

Seaborn's API is designed around Pandas DataFrames. You pass the DataFrame and the column names as strings: sns.boxplot(data=df, x='category', y='value'). This makes it extremely convenient to use in a data analysis workflow without manually extracting arrays.

2 Matplotlib Basics

The core object in Matplotlib is the Figure — the entire canvas. Inside a Figure are one or more Axes objects — each Axes is an individual plot area with its own x-axis, y-axis, title, and data. Understanding the Figure/Axes distinction is essential for building multi-panel dashboards later.

Line Chart — Plotting a Stock Price

In [1]:
import matplotlib.pyplot as plt
import numpy as np

# Simulated 30-day stock price
np.random.seed(42)
days = np.arange(1, 31)
price = 100 + np.cumsum(np.random.randn(30) * 2)  # random walk

fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(days, price, color='steelblue', linewidth=2, label='ACME Corp')
ax.set_title('Stock Price — Last 30 Days', fontsize=14, fontweight='bold')
ax.set_xlabel('Day')
ax.set_ylabel('Price ($)')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

Key plt / ax methods you'll use constantly:

  • ax.set_title(), ax.set_xlabel(), ax.set_ylabel() — annotations
  • ax.legend() — show a legend (uses the label= parameter from each plot call)
  • ax.grid(True, alpha=0.3) — add a subtle grid
  • plt.tight_layout() — prevent labels from being clipped
  • plt.savefig('plot.png', dpi=150) — save to file

Bar Chart — Category Counts

In [2]:
import matplotlib.pyplot as plt

categories = ['Electronics', 'Clothing', 'Books', 'Home', 'Sports']
counts     = [342, 278, 195, 431, 156]
colors     = ['#4C72B0', '#DD8452', '#55A868', '#C44E52', '#8172B2']

fig, ax = plt.subplots(figsize=(8, 5))
bars = ax.bar(categories, counts, color=colors, edgecolor='white', linewidth=0.8)

# Add value labels on top of each bar
for bar, count in zip(bars, counts):
    ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 5,
            str(count), ha='center', va='bottom', fontsize=10)

ax.set_title('Orders by Product Category', fontsize=13, fontweight='bold')
ax.set_ylabel('Number of Orders')
ax.set_ylim(0, max(counts) * 1.15)
plt.tight_layout()
plt.show()

Histogram — Age Distribution

A histogram splits a continuous variable into bins and counts how many values fall in each bin. It's the single most important chart for understanding a feature's distribution before deciding how to preprocess it.

In [3]:
import matplotlib.pyplot as plt
import numpy as np

np.random.seed(0)
ages = np.random.normal(loc=38, scale=12, size=500).clip(18, 80)

fig, ax = plt.subplots(figsize=(8, 5))
ax.hist(ages, bins=30, color='steelblue', edgecolor='white', alpha=0.8)
ax.axvline(ages.mean(), color='red',    linestyle='--', linewidth=2, label=f'Mean: {ages.mean():.1f}')
ax.axvline(np.median(ages), color='orange', linestyle='--', linewidth=2, label=f'Median: {np.median(ages):.1f}')
ax.set_title('Age Distribution of Users', fontsize=13, fontweight='bold')
ax.set_xlabel('Age')
ax.set_ylabel('Frequency')
ax.legend()
plt.tight_layout()
plt.show()

Live recreation of the histogram above — 500 ages drawn from Normal(μ=38, σ=12), clipped to [18, 80]. Hover any bar for its exact bin range and count, or scroll to zoom into a bin.

Scatter Plot — Two Features

In [4]:
import matplotlib.pyplot as plt
import numpy as np

np.random.seed(1)
hours_studied = np.random.uniform(1, 10, 100)
exam_score    = 40 + 5 * hours_studied + np.random.randn(100) * 8

fig, ax = plt.subplots(figsize=(7, 5))
ax.scatter(hours_studied, exam_score, alpha=0.6, color='steelblue', edgecolors='white', s=60)
ax.set_title('Study Hours vs Exam Score', fontsize=13, fontweight='bold')
ax.set_xlabel('Hours Studied')
ax.set_ylabel('Exam Score')
plt.tight_layout()
plt.show()

Live recreation of the scatter plot above — 100 points from exam_score = 40 + 5×hours_studied + noise. Hover a point to read its exact coordinates, or drag to zoom into a region.

3 Seaborn for Statistical Plots

Seaborn excels at statistical plots that would be tedious to build from scratch with Matplotlib. It automatically handles things like computing kernel density estimates, computing quartiles, computing confidence intervals, and applying attractive default themes.

In [5]:
import seaborn as sns
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np

# Set a clean theme
sns.set_theme(style='whitegrid', palette='muted')

np.random.seed(42)
df = pd.DataFrame({
    'score':    np.concatenate([np.random.normal(70, 10, 200),
                                np.random.normal(85, 8, 200)]),
    'group':    ['A'] * 200 + ['B'] * 200,
    'attempt':  np.random.randint(1, 4, 400)
})

# ── Histogram with KDE overlay ──
fig, ax = plt.subplots(figsize=(8, 4))
sns.histplot(df['score'], kde=True, ax=ax, color='steelblue', bins=30)
ax.set_title('Score Distribution with KDE')
plt.tight_layout()
plt.show()

# ── KDE plot comparing two groups ──
fig, ax = plt.subplots(figsize=(8, 4))
sns.kdeplot(data=df, x='score', hue='group', fill=True, alpha=0.4, ax=ax)
ax.set_title('Score Distribution by Group')
plt.tight_layout()
plt.show()

# ── Box plot by group ──
fig, ax = plt.subplots(figsize=(7, 5))
sns.boxplot(data=df, x='group', y='score', palette='muted', ax=ax)
ax.set_title('Score Distribution — Box Plot by Group')
plt.tight_layout()
plt.show()

# ── Violin plot (combines box plot with KDE) ──
fig, ax = plt.subplots(figsize=(7, 5))
sns.violinplot(data=df, x='group', y='score', palette='muted', inner='quartile', ax=ax)
ax.set_title('Score Distribution — Violin Plot')
plt.tight_layout()
plt.show()
💡
histplot vs kdeplot: When to Use Each

Use sns.histplot(kde=True) when you want to see the raw frequency counts and the smooth curve. Use sns.kdeplot() when you want to compare distributions across multiple groups on the same axis — the KDE curves overlap cleanly without the noise of bars. For final reports, KDE overlays are cleaner; for EDA, histograms show the raw data better.

Pair Plot for Multi-Feature Exploration

sns.pairplot() creates a grid of scatter plots for every pair of numeric columns, with histograms on the diagonal. It's one of the most information-dense plots available and should be one of your first EDA steps on any new dataset with fewer than ~10 features.

In [6]:
import seaborn as sns
# Load the built-in Iris dataset
iris = sns.load_dataset('iris')

# pairplot: every feature vs every other feature, color by species
g = sns.pairplot(iris, hue='species', diag_kind='kde', plot_kws={'alpha': 0.6})
g.figure.suptitle('Iris Dataset — Pairplot', y=1.02, fontsize=14)
plt.show()
# Immediately reveals: petal_length and petal_width clearly separate species

4 Box Plots & The Five-Number Summary

Box plots (also called box-and-whisker plots) compress an entire distribution into five statistics and visually flag outliers. They're one of the most compact and information-dense charts for comparing distributions across groups.

The Five-Number Summary

Every box plot encodes five key statistics:

  • Minimum — the smallest non-outlier value (bottom of the lower whisker)
  • Q1 (25th percentile) — 25% of data falls below this value (bottom of the box)
  • Median (Q2, 50th percentile) — the middle value (the line inside the box)
  • Q3 (75th percentile) — 75% of data falls below this value (top of the box)
  • Maximum — the largest non-outlier value (top of the upper whisker)

The IQR (Interquartile Range) is Q3 − Q1. The whiskers extend to Q1 − 1.5×IQR (lower) and Q3 + 1.5×IQR (upper). Any data points beyond the whiskers are plotted individually as dots — these are the outliers.

In [7]:
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

np.random.seed(7)
df = pd.DataFrame({
    'category': ['A']*50 + ['B']*50 + ['C']*50,
    'value':    np.concatenate([
                    np.random.normal(60, 10, 50),
                    np.random.normal(75, 15, 50),
                    np.append(np.random.normal(55, 8, 48), [110, 115])  # two outliers
                ])
})

# Seaborn box plot — by far the most common approach
fig, ax = plt.subplots(figsize=(8, 5))
sns.boxplot(data=df, x='category', y='value', palette='Set2', ax=ax)
ax.set_title('Value Distribution by Category\n(dots beyond whiskers = outliers)', fontsize=12)
ax.set_ylabel('Value')
plt.tight_layout()
plt.show()

# Pure Matplotlib box plot (more control over style)
fig, ax = plt.subplots(figsize=(7, 5))
groups = [df[df['category'] == c]['value'].values for c in ['A', 'B', 'C']]
bp = ax.boxplot(groups, labels=['A', 'B', 'C'], patch_artist=True,
                medianprops={'color': 'red', 'linewidth': 2})
for patch, color in zip(bp['boxes'], ['#4C72B0', '#DD8452', '#55A868']):
    patch.set_facecolor(color)
    patch.set_alpha(0.7)
ax.set_title('Box Plot (Matplotlib style)', fontsize=12)
plt.tight_layout()
plt.show()

Interactive box plot of the exact category A/B/C data from the code above (including the two injected outliers in category C — values 110 and 115). Hover the box to read Q1/median/Q3, hover a whisker cap for min/max, or hover an outlier dot for its exact value.

⚠️
Box Plots Hide Bimodal Distributions

A box plot only shows five summary statistics. If your data is bimodal (two peaks), the box plot will look normal while a histogram or violin plot would reveal the bimodality. Always complement box plots with either a histogram or a violin plot before making conclusions about distribution shape.

5 Visualizing Distributions

Understanding whether your features are normally distributed, right-skewed, left-skewed, or multi-modal is critical for choosing preprocessing steps and understanding which algorithms will perform well. Many ML algorithms assume normally distributed inputs — if your features are heavily skewed, a log transform may be necessary.

Normal vs Skewed Distributions

In [8]:
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
import scipy.stats as stats

np.random.seed(0)

# Three different distributions
normal_data    = np.random.normal(50, 10, 1000)
right_skewed   = np.random.exponential(scale=20, size=1000)     # income-like
left_skewed    = 100 - np.random.exponential(scale=10, size=1000)  # test scores near ceiling

fig, axes = plt.subplots(1, 3, figsize=(15, 4))

titles = ['Normal Distribution', 'Right-Skewed (Positive)', 'Left-Skewed (Negative)']
datasets = [normal_data, right_skewed, left_skewed]

for ax, data, title in zip(axes, datasets, titles):
    sns.histplot(data, kde=True, ax=ax, color='steelblue', bins=40)
    ax.axvline(data.mean(),   color='red',    linestyle='--', label=f'Mean={data.mean():.1f}')
    ax.axvline(np.median(data), color='orange', linestyle=':', label=f'Median={np.median(data):.1f}')
    ax.set_title(title)
    ax.legend(fontsize=8)

plt.suptitle('Distribution Shapes Compared', fontsize=13, fontweight='bold', y=1.02)
plt.tight_layout()
plt.show()

Key interpretation rules:

  • Right-skewed (positive skew): long tail to the right; mean > median > mode. Common for income, house prices, website traffic. Consider log transform before modeling.
  • Left-skewed (negative skew): long tail to the left; mean < median < mode. Common for test scores where most people score high.
  • Symmetric / Normal: mean ≈ median ≈ mode. Standard scaling works well; no transform needed.

Try switching between the three shapes below — same underlying data as the Matplotlib/Seaborn figure above, redrawn as an interactive histogram with KDE-style mean/median markers:

Normal distribution: mean ≈ median, symmetric bell shape.

Comparing Group Distributions with Violin Plots

In [9]:
import seaborn as sns, matplotlib.pyplot as plt, pandas as pd, numpy as np

np.random.seed(42)
df = pd.DataFrame({
    'plan':    np.random.choice(['Basic', 'Standard', 'Premium'], 300),
    'monthly_charge': None
})
df.loc[df['plan'] == 'Basic',    'monthly_charge'] = np.random.normal(25, 5, (df['plan'] == 'Basic').sum())
df.loc[df['plan'] == 'Standard', 'monthly_charge'] = np.random.normal(55, 8, (df['plan'] == 'Standard').sum())
df.loc[df['plan'] == 'Premium',  'monthly_charge'] = np.random.normal(90, 12, (df['plan'] == 'Premium').sum())

fig, ax = plt.subplots(figsize=(9, 5))
sns.violinplot(data=df, x='plan', y='monthly_charge', palette='Set2', inner='quartile', ax=ax)
ax.set_title('Monthly Charge Distribution by Plan — Violin Plot', fontsize=12)
ax.set_ylabel('Monthly Charge ($)')
plt.tight_layout()
plt.show()
# Violin width shows density — wide = more data points there; quartile lines show 25th/50th/75th

6 Correlation Heatmaps

A correlation heatmap visualizes the pairwise correlation between all numeric features in a DataFrame. In one glance, you can identify: which features strongly correlate with the target, which features are redundant because they carry nearly the same information (a problem you'll later hear called multicollinearity), and groups of related features.

Pearson correlation ranges from −1 to +1:

  • +1.0: perfect positive linear relationship
  • 0.0: no linear relationship
  • −1.0: perfect negative linear relationship
  • |r| > 0.7: strong correlation; |r| 0.4–0.7: moderate; |r| < 0.4: weak
In [10]:
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np

np.random.seed(42)
n = 500

# Simulate a dataset where some features are correlated
age     = np.random.randint(25, 65, n)
income  = 20_000 + age * 1200 + np.random.randn(n) * 8000   # correlated with age
tenure  = np.random.randint(1, 20, n)
charges = income * 0.15 + np.random.randn(n) * 500            # correlated with income
churn   = (np.random.rand(n) < 0.05 * (charges / charges.max())).astype(int)

df = pd.DataFrame({
    'age': age, 'income': income, 'tenure': tenure,
    'monthly_charges': charges, 'churned': churn
})

# Compute and plot correlation matrix
corr_matrix = df.corr()

fig, ax = plt.subplots(figsize=(8, 6))
mask = np.triu(np.ones_like(corr_matrix, dtype=bool))  # hide upper triangle (redundant)
sns.heatmap(
    corr_matrix,
    annot=True,
    fmt='.2f',
    cmap='coolwarm',
    center=0,
    vmin=-1, vmax=1,
    mask=mask,
    linewidths=0.5,
    ax=ax
)
ax.set_title('Feature Correlation Heatmap', fontsize=13, fontweight='bold')
plt.tight_layout()
plt.show()

# Quick read: age ↔ income ~0.85 (strong positive), tenure ↔ churn ~-0.10 (weak negative)

Interactive recreation of the correlation matrix above. Hover any cell for the precise Pearson r between that pair of features — notice ageincome and incomemonthly_charges stand out as the strongest relationships.

⚠️
Correlation ≠ Causation

A heatmap showing age and income are highly correlated does not mean age causes higher income. There could be a confounding variable (e.g., years of experience correlates with both). Use correlations to guide feature selection, but be cautious about causal interpretations — those require experimental design or domain knowledge.

7 Subplots for EDA Dashboards

Real EDA requires looking at many charts at once. plt.subplots(nrows, ncols) creates a grid of Axes objects so you can build a multi-panel dashboard in one figure. This is how professional data scientists present their EDA findings.

In [11]:
import matplotlib.pyplot as plt
import seaborn as sns
import pandas as pd
import numpy as np

np.random.seed(42)
n = 400
df = pd.DataFrame({
    'age':            np.random.normal(35, 10, n).clip(18, 70),
    'tenure_months':  np.random.exponential(24, n).clip(1, 72),
    'monthly_charge': np.random.normal(65, 20, n).clip(20, 120),
    'num_calls':      np.random.poisson(3, n),
    'plan':           np.random.choice(['Basic', 'Standard', 'Premium'], n),
    'churned':        np.random.binomial(1, 0.28, n)
})

fig, axes = plt.subplots(2, 2, figsize=(14, 10))
fig.suptitle('Customer Dataset — EDA Dashboard', fontsize=16, fontweight='bold', y=1.01)

# Panel 1: Churn rate by plan (bar chart)
ax1 = axes[0, 0]
churn_by_plan = df.groupby('plan')['churned'].mean().sort_values(ascending=False)
churn_by_plan.plot(kind='bar', ax=ax1, color=['#C44E52', '#4C72B0', '#55A868'], rot=0, edgecolor='white')
ax1.set_title('Churn Rate by Plan')
ax1.set_ylabel('Churn Rate')
ax1.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: f'{y:.0%}'))

# Panel 2: Tenure distribution histogram
ax2 = axes[0, 1]
sns.histplot(df['tenure_months'], kde=True, ax=ax2, color='#4C72B0', bins=30)
ax2.set_title('Tenure Distribution (months)')
ax2.set_xlabel('Tenure (months)')

# Panel 3: Monthly charges by churn status (box plot)
ax3 = axes[1, 0]
sns.boxplot(data=df, x='churned', y='monthly_charge', ax=ax3, palette='Set1')
ax3.set_title('Monthly Charges vs Churn')
ax3.set_xlabel('Churned (0=No, 1=Yes)')
ax3.set_ylabel('Monthly Charge ($)')

# Panel 4: Correlation heatmap
ax4 = axes[1, 1]
numeric_cols = ['age', 'tenure_months', 'monthly_charge', 'num_calls', 'churned']
sns.heatmap(df[numeric_cols].corr(), annot=True, fmt='.2f', cmap='coolwarm',
            center=0, ax=ax4, linewidths=0.5, vmin=-1, vmax=1)
ax4.set_title('Feature Correlation Heatmap')

plt.tight_layout()
plt.savefig('eda_dashboard.png', dpi=150, bbox_inches='tight')
plt.show()
💡
Sharing Axes

When comparing distributions side-by-side, you often want both plots to use the same x-axis scale. Use plt.subplots(1, 2, sharey=True) to link axes. This prevents misleading comparisons where visually similar plots actually have different scales.

🌍

Real-World Spotlight: Customer Churn EDA at a Telecom Company

Imagine you've just joined the analytics team at a telecom company. The business wants to predict customer churn. Before writing a single line of model code, here's the EDA you'd run — a full visualization pipeline covering every major chart type from this lesson:

In [12]:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

np.random.seed(42)
n = 800

# Synthetic telecom dataset
df = pd.DataFrame({
    'contract_type':  np.random.choice(['Month-to-Month', 'One Year', 'Two Year'],
                                        n, p=[0.55, 0.25, 0.20]),
    'tenure_months':  np.random.exponential(scale=28, size=n).clip(1, 72).round(),
    'monthly_charge': np.random.normal(65, 22, n).clip(18, 118).round(2),
    'churned':        None
})

# Churn probability: month-to-month + high charge + low tenure → more churn
m2m     = (df['contract_type'] == 'Month-to-Month').astype(float)
p_churn = 0.4 * m2m + 0.3 * (df['monthly_charge']/118) + 0.3 * (1 - df['tenure_months']/72)
df['churned'] = (np.random.rand(n) < p_churn.clip(0, 1)).astype(int)

sns.set_theme(style='whitegrid', palette='muted', font_scale=1.05)
fig, axes = plt.subplots(2, 2, figsize=(14, 11))
fig.suptitle('Telecom Customer Churn — Full EDA', fontsize=16, fontweight='bold')

# ── Chart 1: Churn rate by contract type (bar chart) ──
ax1 = axes[0, 0]
churn_rate = df.groupby('contract_type')['churned'].mean().sort_values(ascending=False)
bars = ax1.bar(churn_rate.index, churn_rate.values,
               color=['#C44E52', '#4C72B0', '#55A868'], edgecolor='white', linewidth=0.8)
for bar, val in zip(bars, churn_rate.values):
    ax1.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.01,
             f'{val:.1%}', ha='center', fontsize=10, fontweight='bold')
ax1.set_title('Churn Rate by Contract Type', fontweight='bold')
ax1.set_ylabel('Churn Rate')
ax1.set_ylim(0, 1)
ax1.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: f'{y:.0%}'))

# ── Chart 2: Tenure distribution (histogram + KDE) ──
ax2 = axes[0, 1]
sns.histplot(data=df, x='tenure_months', hue='churned', kde=True, ax=ax2,
             bins=30, palette={0: '#4C72B0', 1: '#C44E52'}, alpha=0.6)
ax2.set_title('Tenure Distribution by Churn Status', fontweight='bold')
ax2.set_xlabel('Tenure (months)')
ax2.legend(labels=['Stayed', 'Churned'], title='Churned')

# ── Chart 3: Monthly charges vs churn (box plot) ──
ax3 = axes[1, 0]
sns.boxplot(data=df, x='churned', y='monthly_charge', palette={0: '#4C72B0', 1: '#C44E52'}, ax=ax3)
ax3.set_title('Monthly Charges vs Churn Status', fontweight='bold')
ax3.set_xlabel('Churned (0=Stayed, 1=Churned)')
ax3.set_ylabel('Monthly Charge ($)')

# ── Chart 4: Feature correlation heatmap ──
ax4 = axes[1, 1]
df_enc = pd.get_dummies(df, columns=['contract_type'], drop_first=False)
numeric_df = df_enc[['tenure_months', 'monthly_charge', 'churned',
                      'contract_type_Month-to-Month', 'contract_type_Two Year']]
numeric_df.columns = ['tenure', 'charge', 'churned', 'month-to-month', 'two-year']
sns.heatmap(numeric_df.corr(), annot=True, fmt='.2f', cmap='coolwarm',
            center=0, ax=ax4, linewidths=0.5, vmin=-1, vmax=1)
ax4.set_title('Feature Correlation Heatmap', fontweight='bold')

plt.tight_layout()
plt.savefig('churn_eda.png', dpi=150, bbox_inches='tight')
plt.show()

# Key findings from this EDA:
# - Month-to-Month customers churn at ~52%; Two Year customers at ~8%
# - Churners have much shorter tenure (visible in histogram)
# - Higher monthly charges slightly increase churn (box plot)
# - Contract type is the strongest predictor (heatmap correlation ~-0.45 for two-year)

Four charts, 50 lines of code, and you already have a story: "Contract type is the dominant churn driver. Focus retention on month-to-month customers with tenure under 12 months and monthly charges over $70." That's the power of EDA before modeling.

✍️ Practice Exercise

Use the Seaborn built-in tips dataset (df = sns.load_dataset('tips')) to complete the following visualization tasks:

  1. Create a histogram of the total_bill column with a KDE overlay. Add vertical lines for the mean and median. What is the shape of the distribution?
  2. Create a box plot of tip amount grouped by day. Which day has the most outliers?
  3. Create a correlation heatmap of all numeric columns. Is there a strong correlation between total_bill and tip?
  4. Build a 2×2 subplot dashboard: (1) histogram of total_bill, (2) scatter plot of total_bill vs tip colored by size, (3) box plot of tip by day, (4) violin plot of total_bill by time (lunch/dinner).
▶ Show Solution
In [13]:
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np

df = sns.load_dataset('tips')

# Task 1: Histogram with mean/median
fig, ax = plt.subplots(figsize=(8, 4))
sns.histplot(df['total_bill'], kde=True, ax=ax, color='steelblue', bins=25)
ax.axvline(df['total_bill'].mean(),   color='red',    linestyle='--', label=f"Mean: {df['total_bill'].mean():.2f}")
ax.axvline(df['total_bill'].median(), color='orange', linestyle=':', label=f"Median: {df['total_bill'].median():.2f}")
ax.legend(); ax.set_title('Total Bill Distribution')
plt.tight_layout(); plt.show()
# Distribution is right-skewed (mean > median, long right tail)

# Task 2: Box plot of tip by day
fig, ax = plt.subplots(figsize=(8, 5))
sns.boxplot(data=df, x='day', y='tip', order=['Thur','Fri','Sat','Sun'], palette='Set2', ax=ax)
ax.set_title('Tip Amount by Day')
plt.tight_layout(); plt.show()
# Saturday tends to have the most outlier dots above the whisker

# Task 3: Correlation heatmap
fig, ax = plt.subplots(figsize=(6, 5))
sns.heatmap(df[['total_bill','tip','size']].corr(), annot=True, fmt='.2f',
            cmap='coolwarm', center=0, ax=ax, linewidths=0.5)
ax.set_title('Correlation Heatmap')
plt.tight_layout(); plt.show()
# total_bill & tip: ~0.68 (moderate-strong positive correlation)

# Task 4: 2x2 EDA dashboard
fig, axes = plt.subplots(2, 2, figsize=(13, 10))
fig.suptitle('Tips Dataset — EDA Dashboard', fontsize=14, fontweight='bold')

sns.histplot(df['total_bill'], kde=True, ax=axes[0,0], color='steelblue')
axes[0,0].set_title('Total Bill Distribution')

scatter = axes[0,1].scatter(df['total_bill'], df['tip'], c=df['size'],
                             cmap='viridis', alpha=0.7, s=50)
axes[0,1].set_xlabel('Total Bill'); axes[0,1].set_ylabel('Tip')
axes[0,1].set_title('Total Bill vs Tip (color=party size)')
plt.colorbar(scatter, ax=axes[0,1], label='Party Size')

sns.boxplot(data=df, x='day', y='tip', order=['Thur','Fri','Sat','Sun'],
            palette='Set2', ax=axes[1,0])
axes[1,0].set_title('Tip by Day')

sns.violinplot(data=df, x='time', y='total_bill', palette='muted',
               inner='quartile', ax=axes[1,1])
axes[1,1].set_title('Total Bill by Time')

plt.tight_layout(); plt.show()

📚 Primary Source for This Lesson

Matplotlib Tutorials — official Matplotlib documentation
The official tutorials cover everything from basic plots to advanced customization. Also highly recommended: the Seaborn Tutorial — beautiful gallery with code for every chart type. Use the gallery to find the right chart for your data, then copy the code and adapt it.

💬 Getting a blank plot, a sizing issue, or confused about when to use ax.set_ylabel() vs plt.ylabel()? Paste your code and the output — your tutor will diagnose the issue.