How to Calculate Cramér's V in Python
Cramér's V quantifies the strength of association between two categorical (nominal) variables. Unlike chi-square, which tells you whether an association exists, Cramér's V tells you how strong that...
Key Insights
- Cramér’s V measures association strength between categorical variables on a 0-1 scale, making it ideal for comparing relationships across tables of different sizes
- SciPy 1.7+ provides a built-in
association()function, but understanding the manual calculation helps you implement bias correction for small samples - Always use bias-corrected Cramér’s V when working with samples under 100 observations or sparse contingency tables to avoid inflated association estimates
What Is Cramér’s V and When Should You Use It?
Cramér’s V quantifies the strength of association between two categorical (nominal) variables. Unlike chi-square, which tells you whether an association exists, Cramér’s V tells you how strong that association is on a standardized scale from 0 to 1.
A value of 0 indicates no association—the variables are independent. A value of 1 indicates perfect association—knowing one variable completely determines the other. In practice, you’ll see values somewhere in between.
Use Cramér’s V when:
- Both variables are categorical (nominal or ordinal treated as nominal)
- Your contingency table is larger than 2×2
- You need to compare association strengths across different table sizes
For 2×2 tables specifically, the phi coefficient (φ) is mathematically equivalent and more commonly reported. For ordinal variables where order matters, consider Kendall’s tau or Spearman’s correlation instead.
The Mathematics Behind Cramér’s V
The formula for Cramér’s V is:
V = √(χ² / (n × (k - 1)))
Where:
- χ² is the chi-square statistic from the contingency table
- n is the total sample size
- k is the minimum of (number of rows, number of columns)
The denominator normalizes the chi-square statistic by the maximum possible chi-square value for a table of that size. This normalization is what makes Cramér’s V comparable across different table dimensions.
The (k - 1) term accounts for degrees of freedom in the smaller dimension. For a 3×5 table, k = 3, so you’d divide by n × 2. This ensures the maximum possible value is 1, regardless of table shape.
Manual Calculation with NumPy and SciPy
Let’s build Cramér’s V from scratch to understand each component:
import numpy as np
from scipy.stats import chi2_contingency
def cramers_v_manual(contingency_table):
"""
Calculate Cramér's V from a contingency table.
Parameters
----------
contingency_table : array-like
2D contingency table of observed frequencies
Returns
-------
float
Cramér's V statistic
"""
# Convert to numpy array if needed
table = np.asarray(contingency_table)
# Calculate chi-square statistic
chi2, p_value, dof, expected = chi2_contingency(table)
# Get sample size and dimensions
n = table.sum()
rows, cols = table.shape
k = min(rows, cols)
# Calculate Cramér's V
v = np.sqrt(chi2 / (n * (k - 1)))
return v
# Example: Survey data on education level vs. job satisfaction
observed = np.array([
[30, 20, 10], # High school
[25, 35, 20], # Bachelor's
[15, 25, 40] # Graduate
])
v = cramers_v_manual(observed)
print(f"Cramér's V: {v:.4f}")
This outputs approximately 0.24, indicating a weak-to-moderate association between education level and job satisfaction in this hypothetical data.
The Easy Way: SciPy’s Built-in Function
Starting with SciPy 1.7, you can calculate Cramér’s V with a single function call:
from scipy.stats.contingency import association
import numpy as np
# Same contingency table as before
observed = np.array([
[30, 20, 10],
[25, 35, 20],
[15, 25, 40]
])
# One-liner calculation
v = association(observed, method="cramer")
print(f"Cramér's V: {v:.4f}")
# Other available methods
phi = association(observed, method="pearson") # Phi coefficient (not normalized)
tschuprow = association(observed, method="tschuprow") # Tschuprow's T
print(f"Phi coefficient: {phi:.4f}")
print(f"Tschuprow's T: {tschuprow:.4f}")
The association() function also supports method="tschuprow" for Tschuprow’s T, which uses the geometric mean of (rows-1) and (cols-1) instead of the minimum. This can be more appropriate for tables where both dimensions are meaningful.
Bias-Corrected Cramér’s V for Small Samples
Standard Cramér’s V has a positive bias—it tends to overestimate the true association, especially with small samples or sparse tables. Bergsma (2013) proposed a bias-corrected version:
import numpy as np
from scipy.stats import chi2_contingency
def cramers_v_corrected(contingency_table):
"""
Calculate bias-corrected Cramér's V.
Uses the correction proposed by Bergsma (2013) to reduce
positive bias in small samples.
Parameters
----------
contingency_table : array-like
2D contingency table of observed frequencies
Returns
-------
dict
Contains 'uncorrected', 'corrected', and 'n' values
"""
table = np.asarray(contingency_table)
chi2, p_value, dof, expected = chi2_contingency(table)
n = table.sum()
rows, cols = table.shape
# Standard Cramér's V
k = min(rows, cols)
v_uncorrected = np.sqrt(chi2 / (n * (k - 1)))
# Bias-corrected phi-squared
phi2 = chi2 / n
phi2_corrected = max(0, phi2 - ((rows - 1) * (cols - 1)) / (n - 1))
# Bias-corrected dimensions
rows_corrected = rows - ((rows - 1) ** 2) / (n - 1)
cols_corrected = cols - ((cols - 1) ** 2) / (n - 1)
k_corrected = min(rows_corrected, cols_corrected)
# Bias-corrected Cramér's V
if k_corrected > 1:
v_corrected = np.sqrt(phi2_corrected / (k_corrected - 1))
else:
v_corrected = 0.0
return {
'uncorrected': v_uncorrected,
'corrected': v_corrected,
'n': n,
'chi2': chi2,
'p_value': p_value
}
# Small sample example where bias matters
small_sample = np.array([
[8, 4, 2],
[3, 7, 5],
[2, 4, 10]
])
results = cramers_v_corrected(small_sample)
print(f"Sample size: {results['n']}")
print(f"Uncorrected V: {results['uncorrected']:.4f}")
print(f"Corrected V: {results['corrected']:.4f}")
print(f"Chi-square p-value: {results['p_value']:.4f}")
For this small sample (n=45), you’ll typically see the corrected value is noticeably lower than the uncorrected one. The difference shrinks as sample size increases.
Practical Example: Analyzing Survey Data
Let’s work through a realistic example analyzing relationships between multiple categorical variables:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.stats import chi2_contingency
from scipy.stats.contingency import association
from itertools import combinations
# Simulated survey data
np.random.seed(42)
n_respondents = 500
data = pd.DataFrame({
'age_group': np.random.choice(
['18-30', '31-45', '46-60', '60+'],
n_respondents,
p=[0.25, 0.35, 0.25, 0.15]
),
'education': np.random.choice(
['High School', 'Bachelor', 'Master', 'PhD'],
n_respondents,
p=[0.30, 0.40, 0.20, 0.10]
),
'income_bracket': np.random.choice(
['<50k', '50-100k', '100-150k', '>150k'],
n_respondents,
p=[0.35, 0.35, 0.20, 0.10]
),
'satisfaction': np.random.choice(
['Low', 'Medium', 'High'],
n_respondents,
p=[0.20, 0.50, 0.30]
)
})
# Add some realistic correlations
# Higher education -> higher income (introduce association)
high_ed_mask = data['education'].isin(['Master', 'PhD'])
data.loc[high_ed_mask, 'income_bracket'] = np.random.choice(
['50-100k', '100-150k', '>150k'],
high_ed_mask.sum(),
p=[0.30, 0.40, 0.30]
)
def calculate_cramers_v_matrix(df, columns):
"""
Calculate Cramér's V for all pairs of categorical columns.
Returns a correlation-style matrix.
"""
n_cols = len(columns)
matrix = np.zeros((n_cols, n_cols))
for i, col1 in enumerate(columns):
for j, col2 in enumerate(columns):
if i == j:
matrix[i, j] = 1.0
elif i < j:
# Create contingency table
contingency = pd.crosstab(df[col1], df[col2])
v = association(contingency.values, method="cramer")
matrix[i, j] = v
matrix[j, i] = v
return pd.DataFrame(matrix, index=columns, columns=columns)
# Calculate association matrix
categorical_cols = ['age_group', 'education', 'income_bracket', 'satisfaction']
v_matrix = calculate_cramers_v_matrix(data, categorical_cols)
print("Cramér's V Association Matrix:")
print(v_matrix.round(3))
print()
# Detailed analysis of strongest association
print("Detailed Analysis: Education vs Income")
contingency = pd.crosstab(data['education'], data['income_bracket'])
print("\nContingency Table:")
print(contingency)
chi2, p_value, dof, expected = chi2_contingency(contingency)
v = association(contingency.values, method="cramer")
print(f"\nChi-square: {chi2:.2f}")
print(f"P-value: {p_value:.4f}")
print(f"Cramér's V: {v:.4f}")
# Visualize the association matrix
plt.figure(figsize=(10, 8))
mask = np.triu(np.ones_like(v_matrix, dtype=bool), k=1)
sns.heatmap(
v_matrix,
annot=True,
fmt='.3f',
cmap='Blues',
vmin=0,
vmax=1,
mask=mask,
square=True,
linewidths=0.5
)
plt.title("Cramér's V Association Matrix\n(0 = no association, 1 = perfect association)")
plt.tight_layout()
plt.savefig('cramers_v_heatmap.png', dpi=150)
plt.show()
This produces a heatmap showing association strength between all variable pairs, making it easy to identify which categorical variables are related.
Interpreting Results and Best Practices
Here are widely-used guidelines for interpreting Cramér’s V:
| Value | Interpretation |
|---|---|
| 0.00 - 0.10 | Negligible |
| 0.10 - 0.20 | Weak |
| 0.20 - 0.40 | Moderate |
| 0.40 - 0.60 | Relatively strong |
| 0.60 - 0.80 | Strong |
| 0.80 - 1.00 | Very strong |
These thresholds are rules of thumb, not hard boundaries. Context matters—a V of 0.15 might be meaningful in social science research but trivial in a controlled experiment.
Common pitfalls to avoid:
-
Ignoring sample size: Always report n alongside Cramér’s V. A V of 0.5 with n=30 is far less reliable than V of 0.3 with n=1000.
-
Sparse tables: When many cells have expected frequencies below 5, chi-square (and thus Cramér’s V) becomes unreliable. Consider collapsing categories or using Fisher’s exact test for small tables.
-
Confusing association with causation: Cramér’s V measures association, not causal relationships. A strong V between ice cream sales and drowning deaths doesn’t mean ice cream causes drowning.
-
Comparing across studies without context: A V of 0.25 in a 2×2 table means something different than in a 10×10 table. The maximum achievable V depends on the marginal distributions.
When Cramér’s V isn’t appropriate, consider these alternatives: Goodman and Kruskal’s lambda for asymmetric associations, uncertainty coefficient for information-theoretic interpretation, or polychoric correlation when your ordinal categories represent an underlying continuous variable.