DS 227 · Week 8

Data Exploration II

Multivariate EDA and visualization — how variables move together, how to see it, and the one conclusion you're not allowed to draw.

Knowledge Discovery in Data · University of the Philippines Cebu

Session Map

Between the variables

Words for Today · 1 of 2

Six words for relationships

correlation (r)

A number from −1 to +1 for how closely two columns follow a straight line together. The sign says which way.

e.g. study hours vs score, r = 0.99: they rise together

causation

X causes Y if changing X would change Y. Correlation alone never shows this: correlation is not causation.

e.g. ice cream sales and drownings rise together; summer causes both

confounder

A third variable that drives both X and Y, making them move together without one causing the other.

e.g. fire size drives both firefighters sent and damage done

scatter plot

One dot per row: one column across (x), another up (y).

e.g. ax.scatter(df["study_hours"], df["score"])

trend line

The straight line that best fits the dots in a scatter; its slope says how much y changes per step of x.

e.g. score ≈ 5.1 × study hours + 55.6

heatmap

A grid whose cells are coloured by their value; for a correlation matrix, colour shows sign and strength.

e.g. sns.heatmap(df.corr(), annot=True, center=0)

Words for Today · 2 of 2

Six words for building charts

figure / axes

The figure is the whole picture you save; an axes is one plotting area inside it, with its own x and y.

e.g. fig, ax = plt.subplots() gives one of each

subplot

One of several axes arranged in a grid inside one figure.

e.g. plt.subplots(1, 2) makes two side by side

shared axis

Panels that use exactly the same scale on an axis, so their heights can be compared fairly.

e.g. plt.subplots(1, 2, sharey=True)

hue / facet

Two ways to add a grouping column: hue colours the dots by group; facet draws one panel per group.

e.g. hue="passed"; sns.FacetGrid(df, col="region")

pair plot

A grid of scatter plots for every pair of columns, with each column's histogram on the diagonal.

e.g. sns.pairplot(df) on 3 columns gives a 3 × 3 grid

grouped / stacked bar

Bar charts for two categories at once: grouped puts bars side by side, stacked piles them into one bar.

e.g. completed vs on-going projects, per year

Where We Left Off

A table can whisper a trend — barely

Stare at eight rows and you might sense that more study means higher scores. Might. Multivariate EDA replaces squinting with two tools: a number and a picture.

The printout is a DataFrame (a table): one row per student, numbered 0, 1, 2… on the left, and one column per measurement.

print(df) study_hours sleep_hours score 0 2 8 65 1 5 6 82 2 1 9 60 3 8 5 95 4 6 6 88 5 3 8 70 6 7 5 90 7 4 7 78

The two questions of the week

How strongly do two columns move together? And what does that relationship actually look like?

Part A

Correlation

Co-movement, compressed to a single number.

Last week you summarised one column with a mean and a standard deviation. This part adds one number for two columns: how closely they rise and fall together.

The Number r

One scale, from −1 to +1

Correlation (written r) measures how tightly two variables follow a straight line together. The sign (+ or −) gives direction; the size, ignoring the sign, gives strength.

Two dancers: at +1 they move in perfect step, at −1 one mirrors the other, at 0 they ignore each other.

rReads asExample
+1.0perfect togetherheight in cm vs inches
+0.7strong positivestudy hours vs score
0.0no linear linkshoe size vs exam score
−0.7strong negativeabsences vs grades
−1.0perfect oppositetime used vs time left
All Pairs At Once

.corr() builds the whole matrix

Every numeric column against every other. The result is a correlation matrix: a grid with one row and one column per variable. The diagonal is always 1 — a variable moves perfectly with itself — and the matrix mirrors across it.

df.corr().round(2) study_hours sleep_hours score study_hours 1.00 -0.98 0.99 sleep_hours -0.98 1.00 -0.98 score 0.99 -0.98 1.00
  • df.corr()correlate every numeric column with every other one
  • .round(2)show two decimal places
  • -0.98study and sleep: strongly opposite (more study, less sleep)

Read one cell

study ↔ score = 0.99: in this tiny dataset, study hours and scores rise together almost perfectly.

The Real Matrix

Six columns, every pair at once

One call gives you every pairwise correlation. cols is a list of six numeric column names; df[cols] keeps just those columns. Read it for the big absolute values (big size, either sign) — then go and look at each one.

Scan, do not conclude

A correlation matrix is a list of things worth plotting. It is not a list of findings.

short = df[cols].rename(columns={"duration": "dur", "progress": "prog", "latitude": "lat", "longitude": "lon"}) short.corr().round(3) budget dur prog year lat lon budget 1.000 0.365 -0.021 0.265 -0.054 0.047 dur 0.365 1.000 -0.096 -0.057 -0.104 0.078 prog -0.021 -0.096 1.000 -0.379 -0.026 0.049 year 0.265 -0.057 -0.379 1.000 0.013 -0.027 lat -0.054 -0.104 -0.026 0.013 1.000 -0.785 lon 0.047 0.078 0.049 -0.027 -0.785 1.000
  • .rename(columns={…})shorter column names (old → new), only so the grid fits on screen
  • -0.785lat vs lon: the largest value off the diagonal (more on that soon)
The Headline Pair

Bigger projects take longer — moderately

r = 0.365 across 27,664 finished projects. Positive and believable: more money usually means more work.

r² is the sobering part

r² (r squared) is the share of the ups and downs in one column that a straight line on the other can account for.

0.365² = 0.133. Budget accounts for about 13% of the variation in how long a project takes. The other 87% is something else entirely.

sub = df[["budget", "duration"]].dropna() len(sub) 27664 round(sub.budget.corr(sub.duration), 3) 0.365 round(0.365**2, 3) 0.133 # 13.3% of variance
  • df[["budget", "duration"]].dropna()keep the two columns, then drop every row missing either one
  • sub.budget.corr(sub.duration)the correlation of exactly these two columns (rounded to 3 decimals)
  • 0.365**2** means "to the power of": square r to get r²
Pearson vs Spearman

Same pair, two answers — and the gap is the finding

# Pearson: straight-line strength round(sub.budget.corr(sub.duration), 3) 0.365 # it assumes a LINE fits
# Spearman: rank agreement round(sub.budget.rank().corr( sub.duration.rank()), 3) 0.508 # it only assumes ORDER
Or Fix The Shape

Log the skewed variable and Pearson recovers

The same transform from last week (log₁₀: 10 to the power of what?). On log₁₀(budget), the linear correlation rises from 0.365 to 0.396 — closer to what the ranks were already telling you. lg keeps the positive budgets, because the log of 0 does not exist.

Two routes, same insight

Use Spearman, or transform and use Pearson. What you must not do is report 0.365 as "weak" without checking whether a straight line was ever the right model.

lg = sub[sub.budget > 0] round(np.log10(lg.budget).corr(lg.duration), 3) 0.396 # was 0.365 # and the rank version (Spearman), # for comparison: 0.508 # all three describe the SAME data. # they differ in what they assume.
Bin It To See It

r = 0.365 sounds weak. Look at it in quartiles.

Group the projects into four budget bands and take the median duration of each. The pattern that a scatter plot buried under 27,664 dots becomes unmistakable. d is a copy of sub, the 27,664 finished projects.

6,916 projects in every band

Equal-sized groups by construction, so the comparison is fair and no band is a small-sample fluke.

d["band"] = pd.qcut(d.budget, 4, labels=["Q1 small", "Q2", "Q3", "Q4 large"]) d.groupby("band", observed=True).duration.median() band Q1 small 119.0 Q2 189.0 Q3 226.0 Q4 large 269.0 Name: duration, dtype: float64 # 119 -> 269 days. monotonic.
  • pd.qcut(d.budget, 4, labels=[…])cut the budgets into 4 equal-count bands (quartile bands) and name them
  • d["band"] = …store the band as a new column
  • groupby("band").duration.median()for each band, the median duration in days
That Is What 0.508 Meant

The rank correlation was describing this

Budget bandProjectsMedian durationvs the smallest band
Q1 — smallest6,916119 days—
Q26,916189 days1.6×
Q36,916226 days1.9×
Q4 — largest6,916269 days2.3×
Handle With Care

Three things a correlation can't tell you

A Correlation Of −0.785

latitude vs longitude

Strong. Real. Reproducible. And completely empty.

Nothing Causes This

It is the shape of the country

Across 30,884 geocoded projects, latitude and longitude correlate at −0.785 — stronger than any other pair in the matrix. No mechanism exists, and none is needed.

The Philippines runs northwest to southeast

Go further south and you are also further east. The correlation is a fact about the archipelago’s outline, not about flood control.

ll = df[["latitude", "longitude"]].dropna() len(ll) 30884 round(ll.latitude.corr(ll.longitude), 3) -0.785 # the strongest pair ll.latitude.agg(["min", "max"]).round(2).tolist() [5.01, 20.79] ll.longitude.agg(["min", "max"]).round(2).tolist() [117.06, 126.57]
  • .agg(["min", "max"])two summaries at once: lowest and highest latitude (degrees north); .tolist() prints them as a plain list
  • [5.01, 20.79]from Mindanao's south to Luzon's north; longitude (degrees east) runs 117 to 127
Why This Is The Best Example

Nobody is tempted to invent a story

Quick Check

Tap to reveal

In the matrix, study_hours ↔ sleep_hours = −0.98. What does that number claim?

A · Studying causes sleep loss
B · Students who studied more tended to sleep less — a strong opposite pattern
C · The data is broken; negatives are errors
D · Sleep barely relates to study

B — a strong pattern, and only a pattern.

The magnitude (0.98) says the opposite movement is very consistent; the sign says which way. What it does not say is why — maybe a fixed number of evening hours forces the trade-off. Causes are Part C's business.

Break

  Five minutes

Then: pictures that do what the number can't.

Part B

Seeing Relationships

One point per row, and the truth comes out.

You now have r, one number per pair. This part draws the pairs, so you can see curves, clusters and odd points that r hides.

The Two-Variable Workhorse

The scatter plot: one dot per row

A scatter plot draws one dot per row: x (across) from one column, y (up) from another. Trend, tightness, curves, strays — all visible at once, no summary in the way.

fig, ax = plt.subplots() ax.scatter(df["study_hours"], df["score"]) ax.set_xlabel("Study hours") ax.set_ylabel("Score") plt.show()
  • fig, ax = plt.subplots()a blank chart: fig the whole picture, ax the plotting area (last week)
  • ax.scatter(df["study_hours"], df["score"])first column across, second column up: one dot per student
  • set_xlabel / set_ylabelname each axis, so a reader knows what the dots mean

What you'll see in the lab

Eight dots climbing steadily up-right — the shape that 0.99 was compressing into one number.

What That Code Draws

Eight dots climbing up and to the right

Scatter plot of study hours (across, 1 to 8) against score (up, 60 to 95) for the eight students; the dots rise almost in a straight line from 1 hour and 60 points to 8 hours and 95 points.
Adding A Trend Line

One straight line through the dots

A trend line is the straight line that fits the dots best (closest overall). Its slope is how much y changes when x goes up by 1; its intercept is where it crosses x = 0.

Stretch a string through a cloud of pins so it passes as close to all of them as it can. The string's tilt is the slope.

Describes, does not explain

"Each extra study hour goes with about 5.1 more points" in these 8 students. Not "causes": that is Part C.

import numpy as np slope, intercept = np.polyfit(df["study_hours"], df["score"], 1) print(round(slope, 1), round(intercept, 1)) 5.1 55.6 xs = np.array([1, 8]) ax.scatter(df["study_hours"], df["score"]) ax.plot(xs, slope * xs + intercept)
  • np.polyfit(x, y, 1)find the best straight line (the 1 means a line, not a curve); it gives back two numbers
  • slope, intercept = …two names on the left catch the two numbers
  • 5.1 55.6score ≈ 5.1 × hours + 55.6: each extra hour goes with about 5 more points
  • ax.plot(xs, slope * xs + intercept)draw the line from 1 hour to 8 hours on top of the dots
What That Code Draws

The line passes close to every dot

The same eight-dot scatter with a straight line drawn from about 60.7 points at 1 hour to about 96.3 points at 8 hours; every dot sits close to the line.
27,664 Dots

The scatter plot, and its first problem

Plot budget against duration for every finished project and the middle becomes a solid block. You cannot see density (how crowded each area is), only outline.

Overplotting hides the mass

Overplotting: so many dots on top of each other that you cannot count them.

Where most of your data lives is exactly where the plot stops being readable.

df.plot.scatter(x="budget", y="duration") # → a solid blue wedge near the origin, # a few dots trailing right # three fixes, in order of preference alpha=0.05 df.sample(2000, random_state=42).plot.scatter(...) df.plot.hexbin(x=..., y=..., gridsize=40)
  • alpha=0.05transparency from 0 (invisible) to 1 (solid): crowded areas darken where dots pile up
  • df.sample(2000, random_state=42)draw a random 2,000 rows; random_state fixes the draw so it repeats
  • df.plot.hexbin(…, gridsize=40)cover the chart in honeycomb cells and colour each by how many dots fall in it
What That Code Draws

27,664 dots, one solid block

Scatter of budget (across, 0 to about 1.05 billion pesos, marked 1e9) against duration (up, 0 to about 2,700 days); almost all the dots merge into a solid blue block near the bottom-left corner, with a few dots trailing to the right.
What That Code Draws

Two of the fixes: fewer dots, or count them in cells

Scatter of a random 2,000 projects, budget against duration; the block thins out so single dots are visible, and the budget axis now ends near 700 million pesos (7 on a 1e8 scale).Hexbin chart of budget against duration: the plot is covered in hexagonal cells shaded from white to dark green by how many projects fall in each; the darkest cells, over 3,000 projects each, sit right next to the origin.
Log The Axis, Not The Data

The same plot, readable

You do not have to transform the column to fix the picture — just the axis. On a log scale each equal step multiplies (1M, 10M, 100M). The tick labels stay in pesos, so the reader needs no translation.

Say it in the caption

"x-axis is logarithmic" is one clause and prevents a reader from misjudging every distance on the chart.

ax = df.plot.scatter(x="budget", y="duration", alpha=0.05) ax.set_xscale("log") # now the 20M-to-100M band, where # ~60% of projects live, is visible # and the relationship looks like # what spearman=0.508 implied
What That Code Draws

Log axis plus transparency: the crowd becomes visible

Scatter of budget on a logarithmic x axis (10 to the 6 up to 10 to the 9 pesos) against duration in days, drawn with very faint dots; the dots form a dense band between 10 million and 100 million pesos, and the cloud tilts upward to the right.
Many Pairs At A Glance

Heatmap: the matrix, painted

Color encodes each correlation — warm for positive, cool for negative, pale near zero. Ten variables means 45 pairs; your eye finds the hot spots instantly.

A weather map of the matrix: you spot the hot and cold regions before you read a single number.

import seaborn as sns sns.heatmap(df.corr(), annot=True, cmap="coolwarm", center=0)

The settings that matter

annot=True prints the numbers on the cells; center=0 anchors white at zero so sign is instantly readable.

What That Code Draws

Three columns, nine coloured cells

red cells are pairs that rise together, blue cells the pair that moves in opposite directions. Nothing is pale, because every pair here is strongly related. The diagonal is 1: each column against itself.

3-by-3 heatmap of the correlations between study_hours, sleep_hours and score, each cell labelled with its value: dark red cells for 1 and 0.99, dark blue cells for -0.98, with a colour bar on the right.
Every Pair, Drawn

The pair plot: the matrix as scatter plots

A heatmap gives one colour per pair. A pair plot draws the actual scatter for every pair of columns, in a grid, with each column's histogram on the diagonal. It is the picture behind every number in .corr().

A heatmap is the table of contents; the pair plot is the book. Use the first to choose, the second to check.

Keep it small

3 columns make 9 panels; 10 columns make 100. Pick the handful the heatmap pointed at.

import seaborn as sns g = sns.pairplot(df) # study, sleep, score g.axes.shape (3, 3) # add hue= to colour every panel by a group # sns.pairplot(df, hue="passed")
  • sns.pairplot(df)seaborn draws one panel for every pair of numeric columns (in Colab; not the browser lab)
  • g.axes.shapethe grid of axes it made: (3, 3) means 3 rows by 3 columns of panels
  • diagonal panelsa column against itself would be a straight line, so each shows that column's histogram instead
What That Code Draws

Nine panels: three histograms, six scatters

each off-diagonal panel is one pair. Study hours and score climb together; every panel with sleep hours falls. The panels above the diagonal repeat the ones below it with x and y swapped, and the diagonal shows each column's own histogram.

Pair plot of study_hours, sleep_hours and score: a 3-by-3 grid with a histogram of each column on the diagonal and scatter plots elsewhere; the study-hours and score panels rise, every sleep-hours panel falls.
Heatmap

The matrix, painted

Six columns is fifteen pairs. A heatmap colours each cell of the matrix by its value, so you find the strong ones without reading a grid of numbers.

Diverging colormap, centred on zero

A colormap turns numbers into colours. A diverging one runs blue → white → red, so negative and positive look different. A sequential one (pale → dark) renders −0.785 and +0.785 nearly the same.

import seaborn as sns sns.heatmap(df[cols].corr(), annot=True, fmt=".2f", cmap="RdBu_r", center=0, vmin=-1, vmax=1) # center=0 and a diverging cmap are # not decoration — without them the # sign of r is hard to see
  • import seaborn as snsload seaborn, a chart library built on matplotlib (in Colab; not in the browser lab)
  • annot=True, fmt=".2f"write each r on its cell, with two decimals
  • cmap="RdBu_r", center=0red-blue colours, white exactly at r = 0
  • vmin=-1, vmax=1fix the colour scale to the full −1 to +1 range
What That Code Draws

Mostly pale: one dark pair stands out

almost every cell off the diagonal is pale: most pairs barely move together. Latitude vs longitude is the one dark blue pair (−0.79, which is −0.785 rounded by fmt=".2f"); progress vs year and budget vs duration are the only other visible tints.

6-by-6 heatmap of correlations between budget, duration, progress, year, latitude and longitude, red for positive and blue for negative with white at zero, each cell labelled to two decimals; the darkest off-diagonal cells are latitude with longitude at -0.79 in dark blue, then progress with year at -0.38 and budget with duration at 0.37.
Division Of Labour

The number ranks; the picture explains

Correlation is compact — perfect for scanning many pairs. The scatter is rich — mandatory before you believe any single pair. Use them in that order.

Step 1 · Scan with r

The matrix (or heatmap) points at the interesting pairs in seconds.

Step 2 · Verify with a scatter

Is it truly linear? One cluster or two? Is one point doing all the work?

Step 3 · Only then, claim

"Strong, roughly linear, no obvious outliers" — now the r you quote means something.

A Third Dimension

Color the dots by a category

A scatter takes two variables; hue, seaborn's word for "colour the dots by this column", sneaks in a third. If the study–score trend differs between students who passed and those who did not, colored dots show it immediately.

df["passed"] = df["score"] >= 75 sns.scatterplot(data=df, x="study_hours", y="score", hue="passed")

Why it matters

A trend that holds overall can vanish — or reverse — inside subgroups. Seeing groups separately is the first defense against being fooled by the aggregate.

In the lab

You'll colour the dots by a third column (sleep hours) with matplotlib's c=: the same idea, without seaborn.

A Third Variable

Colour, size, or a separate panel

Two axes are used up. There are three honest ways to add one more variable, and one dishonest one. To facet is to repeat the same chart once per group, in a grid of small panels: those panels are called small multiples.

Never use colour for a number on a scatter

Readers cannot rank colours accurately. Use it for categories; use position or size for quantities.

# keep every column, drop rows missing either number d = df.dropna(subset=["budget", "duration"]) # category -> colour sns.scatterplot(data=d, x=..., y=..., hue="status") # quantity -> size (with care) plt.scatter(x, y, s=budget/1e6) # best for many groups -> small multiples g = sns.FacetGrid(d, col="region", col_wrap=4) g.map(plt.scatter, "budget", "duration")
  • hue="status"one colour per status value
  • s=budget/1e6s is dot size: bigger budget, bigger dot
  • sns.FacetGrid(d, col="region", col_wrap=4)one panel per region, four panels per row
  • g.map(plt.scatter, "budget", "duration")draw the same scatter in every panel
What That Code Draws

Eighteen regions, one shared scale

because every panel uses the same axes, you can compare them directly. Central Office is the only region whose projects reach far to the right (up to about 1 billion pesos), and it tilts upward most clearly; most other panels are a tall blob near zero budget.

# d here keeps every column d = df.dropna(subset=["budget", "duration"])

Why a fresh d: FacetGrid needs the region column. The earlier d (a copy of sub, budget and duration only) has none and would raise KeyError: 'region'.

Open the chart full size

Eighteen small scatter plots of budget against duration, one per region, four per row, all on the same axes (duration 0 to about 2,700 days, budget 0 to about 1 billion pesos); in almost every panel the dots crowd into the bottom-left corner, while the Central Office panel spreads far to the right and upward.
Several Charts, One Figure

Figure, axes, subplots and a shared axis

The figure is the whole picture you save. Each axes is one plotting area inside it. Ask plt.subplots for a grid and each cell is a subplot. With sharey=True the panels get a shared axis: one y scale for all.

The figure is a sheet of paper; each axes is a box you draw a chart in. Sharing the y axis means every box uses the same ruler.

fig, axes = plt.subplots(1, 2, figsize=(8, 3), sharey=True) axes[0].scatter(df["study_hours"], df["score"]) axes[1].scatter(df["sleep_hours"], df["score"]) print(axes.shape) (2,) print("shared y:", axes[0].get_ylim() == axes[1].get_ylim()) shared y: True
  • plt.subplots(1, 2, figsize=(8, 3))1 row, 2 columns of axes, in a figure 8 inches wide and 3 tall
  • axes[0], axes[1]the left and right panel, counted from 0
  • (2,)the shape of axes: a row of two panels
  • shared y: Trueboth panels show the same y range, so heights compare fairly
What That Code Draws

Two panels, one ruler for score

Two side-by-side scatter plots sharing one y axis for score (60 to 95): on the left, study hours 1 to 8 with the dots rising; on the right, sleep hours 5 to 9 with the dots falling. Only the left panel shows y-axis numbers.
Small Multiples Beat One Busy Chart

Eighteen small panels, one shared scale

Choosing The Chart

What you are comparing decides the picture

QuestionChartWatch out for
One numeric column’s shapeHistogramBin count changes the story
Two numeric columnsScatterOverplotting; log the skewed axis
One numeric across groupsBoxplot, ordered by medianAlphabetical order hides the pattern
Two categoricalsCrosstab, normalisedRaw counts just show group size
Many pairs at onceHeatmap, diverging, centred on 0Sequential colormaps hide the sign
Many groups, same relationshipSmall multiplesUnshared axis limits
Part C

The Causation Trap

The most expensive four words in data: "so it must cause."

You can now measure and draw a relationship. This part compares groups, and asks the hard question: what could have produced it?

Comparing Groups

Split, then summarize

Another face of multivariate EDA: a category splits the rows, and you compare summaries across the split — passers vs non-passers, region vs region.

df["passed"] = df["score"] >= 75 df.groupby("passed")[ ["study_hours", "sleep_hours"] ].mean().round(1) study_hours sleep_hours passed False 2.0 8.3 True 6.0 5.8
  • df["passed"] = df["score"] >= 75a new column: True if the score is 75 or more, else False
  • groupby("passed")[[…]].mean()split into the False and True piles, then average two columns in each; .round(1) keeps one decimal
  • True 6.0 5.8passers averaged 6 study hours and 5.8 sleep hours

Read it carefully

Passers studied more and slept less — in this data. A difference between groups is a fact; its cause is still an open question.

Comparing Groups

One box per region, eighteen distributions at once

A histogram shows one distribution well. To compare eighteen, the boxplot is the only chart that stays readable.

Order by the median

Alphabetical order hides the pattern. Sorted, the chart answers "where is this fast and where is it slow?" at a glance.

order = (df.groupby("region").duration .median().sort_values().index) sns.boxplot(data=df, x="duration", y="region", order=order) # horizontal: region names stay readable # vertical: they overlap and rotate
  • groupby("region").duration.median()median duration per region
  • .sort_values().indexsort those medians and keep just the region names, fastest first
  • sns.boxplot(…, order=order)one box per region, drawn in that sorted order
What That Code Draws

Sorted by median, the boxes drift right

Horizontal box plots of project duration in days for 18 regions, sorted by median from Region I at the top to Central Office at the bottom; most boxes sit between about 100 and 400 days, many outlier circles stretch right to about 2,700 days, and the Central Office box is clearly further right than the rest.
What The Boxes Say

A 93-day gap between the fastest and slowest region on the map

RegionProjectsQ1MedianQ3
Region I2,730100 d167 d246 d
Region III4,428121 d170 d235 d
Region VIII1,586120 d178 d257 d
Region X961179 d252 d348 d
Region VI881169 d260 d390 d
Two Categories

Rates, not counts, when the groups differ in size

Region III has 5,412 projects and Central Office 180. Comparing raw completed counts would say nothing except which region is bigger. A crosstab counts the rows for every combination of two categories (region × status).

A 26-point spread

Completion rates run from 65.6% in Region IX to 92.0% in Region XII. Normalising is what makes that visible.

rate = (pd.crosstab(df.region, df.status, normalize="index")["Completed"] .sort_values().round(3)) rate.iloc[[0, -1]] # lowest and highest of 18 region Region IX 0.656 Region XII 0.920 Name: Completed, dtype: float64 # each row sums to 1, so regions of # very different sizes compare fairly
  • normalize="index"divide each row by its row total, so each region's statuses add up to 1
  • ["Completed"]keep one column: the share of each region's projects that are completed
  • ( … )the outer brackets let the command continue onto the next line
  • rate.iloc[[0, -1]]rows by position: the first (0) and the last (−1) of the sorted list
Two Categories In One Chart

Grouped bars compare; stacked bars add up

A crosstab of year × status is two categories at once. A grouped bar chart puts one bar per status side by side for each year: easy to compare statuses. A stacked bar chart piles them into one bar per year: easy to see the total, harder to compare the upper pieces.

What the numbers show

2025 projects are mostly on-going, 2023 projects mostly completed: the same "elapsed time" story as progress vs year.

ct = pd.crosstab(df.year, df.status) few = ct.loc[2023:2025, ["Completed", "On-Going"]] few status Completed On-Going year 2023 3842 613 2024 3216 1855 2025 375 2615 few.plot.bar() # grouped few.plot.bar(stacked=True) # stacked
  • pd.crosstab(df.year, df.status)count projects for every (year, status) combination
  • ct.loc[2023:2025, […]]keep the rows 2023 to 2025 and two status columns
  • few.plot.bar()one group of bars per year, one bar per status: 6 bars
  • stacked=Truethe same 6 pieces piled into 3 bars, one per year
What That Code Draws

Same six numbers, two different questions

Grouped bar chart for 2023, 2024 and 2025 with a blue Completed bar and an orange On-Going bar per year: Completed falls from 3,842 to 375 while On-Going rises from 613 to 2,615.Stacked bar chart of the same numbers: one bar per year with Completed in blue at the bottom and On-Going in orange on top; totals are about 4,455, 5,071 and 2,990.
The Big One

"X and Y move together" has three explanations

Staying Honest

EDA generates hypotheses — it doesn't settle them

Finding a strong association is a beginning: now you know which question is worth the harder tools — controlled comparisons, experiments, domain knowledge.

EDA can say

"Study hours and scores are strongly associated (r = 0.99, linear, n = 8)."

EDA cannot say

"One more study hour causes 5.1 more points." (5.1 is the slope of the trend line.) That claim needs a design, such as an experiment, not a scatter.

And always report n

Eight students is a whisper, not a verdict. Small samples make large correlations cheap.

The Obvious One

progress vs year, r = −0.379

Newer projects show less progress. Stated as a coefficient it sounds like a finding. Stated as a sentence it is a tautology: true by definition, so it tells you nothing new.

Time is the mechanism

A project started in 2025 has had less time to finish than one started in 2021. The correlation is real, and it tells you nothing you did not already know.

df.groupby("year").progress.mean().round(1).loc[2021:] # (2016 to 2020: 99.0 to 99.7) year 2021 99.4 2022 98.9 2023 97.2 2024 83.8 2025 57.1 2026 0.0 Name: progress, dtype: float64 # not a decline in performance. # a decline in elapsed time.
Same Pair, Eighteen Answers

The national number hides the variation

RegionProjectsr (budget vs duration)
National Capital Region3,373+0.090 — almost none
Region IV-B1,116+0.112
…national average…27,664+0.365
Region IV-A2,735+0.554
Region VIII1,586+0.623 — strong
The Habit

Always ask: does this hold within groups?

Before reporting any correlation, compute it again inside the obvious subgroups. If the answer moves a lot, the aggregate figure is a summary of different things.

When it reverses, it has a name

A relationship that flips sign inside every group is Simpson’s paradox. Here it does not reverse — but it ranges from 0.09 (NCR) to 0.66 (Central Office, only 122 projects), which is enough to make the average misleading.

r = (df.dropna(subset=["budget", "duration"]) .groupby("region")[["budget", "duration"]] .apply(lambda g: g.budget.corr(g.duration)) .sort_values(ascending=False).round(3)) r.iloc[[0, 1, -2, -1]] # top two, bottom two region Central Office 0.655 Region VIII 0.623 Region IV-B 0.112 National Capital Region 0.090 dtype: float64 # filter to groups with enough rows # before you trust any of them
  • dropna(subset=[…])drop rows missing a budget or a duration
  • .apply(lambda g: …)run a small function on each region's pile of rows g (just the two columns picked in [[…]]): here, its own r
  • Central Office 0.655the top value rests on only 122 projects: exactly the warning in the comment
Your Turn · 8 min

Interrogate a correlation

1 · Pick a pair

Any two numeric columns. Compute Pearson and Spearman on them.

2 · Explain the gap

If they differ by more than ~0.1, say why. Plot the scatter to check your explanation.

3 · Split it

Recompute within regions (or within years). How much does r move? Report the range, not just the average.

4 · Write the honest sentence

One sentence stating the relationship, the strength, and one thing it does not establish.

Quick Check

Tap to reveal

budget vs duration gives Pearson 0.365 but Spearman 0.508. What does that gap tell you?

A · One of the two calculations is wrong
B · The relationship is monotonic but not linear — consistent with a strongly skewed budget column
C · There is no real relationship; the numbers disagree
D · Spearman always returns a larger value than Pearson
B. Spearman only assumes order; Pearson assumes a straight line. When ranks agree more than the raw values do, the relationship is real but curved — here because budget has skew 5.13. Taking log₁₀(budget) raises Pearson to 0.396, which confirms it. D is false: the order can go either way.
Quick Check

Tap to reveal

latitude and longitude correlate at −0.785 across 30,884 projects. What is the correct interpretation?

A · Southern regions receive systematically different funding
B · It reflects the geographic shape of the archipelago and carries no causal content
C · The coordinates were recorded incorrectly
D · With n = 30,884 the correlation must be meaningful
B. The Philippines runs northwest to southeast, so going south means going east. The coefficient is strong, the sample is large, and it still means nothing about flood control. A large n makes an estimate precise, not meaningful — and the dangerous version of this is the pair where you can invent a mechanism.
Quick Check

Tap to reveal

Across cities: the more firefighters sent to a fire, the larger the fire damage (strong positive r). Should cities send fewer firefighters?

A · Yes — the data says firefighters cause damage
B · No — fire size confounds: big fires get more firefighters AND do more damage
C · Yes, but only for small fires
D · The correlation must be a computation error

B — the confounder is the fire itself.

Fire size drives both variables, creating a real correlation with an absurd causal reading. Compare damage within fires of similar size and the "firefighters cause damage" story evaporates.

Glossary

Every new word from today, in one line each

Relationships

multivariate
several variables at once
correlation (r)
−1 to +1: how closely two columns follow a line
correlation matrix
r for every pair of columns, in a grid
r²
share of the variation a straight line accounts for
Pearson / Spearman
r on the values / r on their ranks
monotonic / linear
always the same direction / a straight line
causation
changing X would change Y; correlation is not causation
confounder
a third variable that drives both X and Y
trend line
best-fitting straight line; its slope is y per unit of x
crosstab
counts for every combination of two categories
tautology
true by definition, so it tells you nothing
n
the number of rows a statistic rests on

Charts

scatter plot
one dot per row, x across and y up
overplotting
too many dots on top of each other to count
log scale
an axis where each step multiplies (1M, 10M, 100M)
heatmap
grid of cells coloured by value
colormap
numbers to colours; diverging for ± values
pair plot
scatter for every pair, histograms on the diagonal
figure / axes
the whole picture / one plotting area in it
subplot
one axes in a grid of axes
shared axis
every panel uses the same scale
hue / facet
colour by group / one panel per group
small multiples
the same chart repeated per group
grouped / stacked bar
bars side by side / piled into one
This Week's Lab

Eight students, three variables

Build the frame, read its correlation matrix, scatter the strongest pair, and paint the heatmap. Then explain a negative correlation without the word "causes." ~45 minutes.

You'll practise

df.corr(), ax.scatter() with labels, and a heatmap drawn with matplotlib's ax.imshow (seaborn does not run in the browser lab).

Stretch, if you're quick

A scatter coloured by a third column, and two side-by-side panels that share one y axis.

Recap

Four things to carry out

Readings

Before next week

Next Week

Data Journalism

Finding stories in data — turning associations, gaps and outliers into questions the public deserves answered.

DS 227 · Knowledge Discovery in Data