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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.