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?
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:
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:
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?
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:
# 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}} $$
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.
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")
Bivariate Analysis with ggplot2: Survival Rate by Class and Sex
Faceted bar chart showing conditional survival rates, with stat_summary for error bars.
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)