SECTION 1: LEARNING OBJECTIVES

By the end of this lesson, you will be able to:

  • Understand the fundamentals of Analysis of Variance (ANOVA) and its application in comparing multiple groups in financial data.

  • Distinguish between one-way and two-way ANOVA and identify appropriate use cases for each.

  • Perform one-way ANOVA to compare means across three or more groups using Python.

  • Conduct post-hoc analysis (Tukey’s HSD) to identify which specific groups differ significantly.

  • Apply two-way ANOVA to assess the effect of two categorical variables and their interaction.

  • Interpret ANOVA tables including degrees of freedom, sum of squares, mean squares, F-statistic, and p-values.

  • Validate ANOVA assumptions (normality, homogeneity of variance, independence) in financial datasets.

  • Apply non-parametric alternatives (Kruskal-Wallis) when ANOVA assumptions are violated.

  • Use ANOVA in banking contexts such as comparing customer segments, portfolio performance, and risk metrics.


SECTION 2: INTRODUCTION TO ANALYSIS OF VARIANCE

2.1 Why ANOVA?

In financial data analytics, we often need to compare metrics across more than two groups. For example:

  • Comparing average transaction amounts across Premium, Standard, and Basic customers

  • Comparing portfolio returns across Financial, Energy, and Technology sector ETFs

  • Comparing credit scores across different geographic regions

  • Comparing loan default rates across various customer segments

While t-tests can compare two groups, they become inefficient and increase the risk of Type I error (false positives) when comparing multiple groups. If we compare 3 groups using pairwise t-tests, we would need 3 separate tests. For 4 groups, we would need 6 tests. Each test carries a 5% risk of false positive, so the overall error rate becomes unacceptably high.

ANOVA (Analysis of Variance) solves this problem by testing all groups simultaneously in a single test, controlling the overall error rate.

Definition: Analysis of Variance (ANOVA) is a statistical technique used to test for differences or correlations in the effects of independent variables on a dependent variable. It divides the total variability of a variable into different sources.

2.2 The Core Logic: Variance Decomposition

ANOVA works by partitioning the total variation in the data into two components:

  1. Between-group variation – variation explained by the group differences

  2. Within-group variation – variation due to random error (unexplained)

Total Sum of Squares (SST) = Sum of Squares Between (SSB) + Sum of Squares Within (SSW)

The F-statistic is the ratio of between-group variance to within-group variance:

F = (SSB / df_between) / (SSW / df_within) = MSB / MSW

  • If groups are significantly different → MSB is large relative to MSW → Large F-statistic

  • If groups are similar → MSB is small relative to MSW → Small F-statistic

A large F-statistic leads to a small p-value, indicating that at least one group mean is significantly different from the others.


SECTION 3: ASSUMPTIONS OF ANOVA

Before performing ANOVA, we must verify three key assumptions:

3.1 Normality
  • The data within each group should be approximately normally distributed

  • Test: Shapiro-Wilk test, or visual inspection using Q-Q plots

  • Note: ANOVA is relatively robust to moderate violations of normality

3.2 Homogeneity of Variance (Homoscedasticity)
  • The variances across all groups should be roughly equal

  • Test: Levene’s test or Bartlett’s test

  • Note: If violated, consider Welch’s ANOVA (available in scipy.stats.welch)

3.3 Independence
  • Observations should be independent of each other

  • Random sampling ensures this assumption is met

python
# ============= CHECKING ANOVA ASSUMPTIONS =============

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
import statsmodels.api as sm
from statsmodels.formula.api import ols
from statsmodels.stats.multicomp import pairwise_tukeyhsd

print("="*60)
print("CHECKING ANOVA ASSUMPTIONS")
print("="*60)

# Generate sample data: Transaction amounts by customer segment
np.random.seed(42)
n = 50

premium = np.random.normal(350, 80, n)
standard = np.random.normal(200, 60, n)
basic = np.random.normal(120, 40, n)

# Combine into DataFrame
df = pd.DataFrame({
    'amount': np.concatenate([premium, standard, basic]),
    'segment': ['Premium']*n + ['Standard']*n + ['Basic']*n
})

print("\n1. NORMALITY TEST (Shapiro-Wilk)")
print("-" * 40)

for segment in ['Premium', 'Standard', 'Basic']:
    data = df[df['segment'] == segment]['amount']
    stat, p = stats.shapiro(data)
    print(f"  {segment}: W={stat:.4f}, p={p:.4f}")
    if p > 0.05:
        print(f"    ✓ Data appears normal (p > 0.05)")
    else:
        print(f"    ⚠ Data may not be normal (p < 0.05)")

print("\n2. HOMOGENEITY OF VARIANCE (Levene's Test)")
print("-" * 40)

groups = [df[df['segment'] == s]['amount'] for s in ['Premium', 'Standard', 'Basic']]
stat, p = stats.levene(*groups)
print(f"  Levene Statistic: {stat:.4f}")
print(f"  P-Value: {p:.4f}")
if p > 0.05:
    print(f"    ✓ Variances are roughly equal (p > 0.05)")
else:
    print(f"    ⚠ Variances may not be equal (p < 0.05)")

# Visual check: Q-Q plots for each group
fig, axes = plt.subplots(1, 3, figsize=(15, 4))

for i, segment in enumerate(['Premium', 'Standard', 'Basic']):
    data = df[df['segment'] == segment]['amount']
    stats.probplot(data, dist="norm", plot=axes[i])
    axes[i].set_title(f'Q-Q Plot - {segment}')

plt.tight_layout()
plt.savefig('anova_assumptions.png', dpi=300)
plt.show()

SECTION 4: ONE-WAY ANOVA

One-way ANOVA compares means across three or more groups based on one categorical independent variable (factor).

4.1 Hypotheses
  • Null Hypothesis (H₀): μ₁ = μ₂ = μ₃ = … = μₖ (all group means are equal)

  • Alternative Hypothesis (H₁): At least one group mean is different

4.2 Financial Example: ETF Returns by Sector

An investment analyst evaluates the 10-year mean return on investment for industry-specific ETFs across three sectors: Financial, Energy, and Technology.

python
# ============= ONE-WAY ANOVA - ETF RETURNS EXAMPLE =============

print("\n" + "="*60)
print("ONE-WAY ANOVA: ETF RETURNS BY SECTOR")
print("="*60)

# Simulate 10-year returns for 30 ETFs per sector
np.random.seed(42)
n_etfs = 30

financial_returns = np.random.normal(0.08, 0.03, n_etfs)   # 8% avg return
energy_returns = np.random.normal(0.05, 0.04, n_etfs)      # 5% avg return
tech_returns = np.random.normal(0.12, 0.035, n_etfs)       # 12% avg return

# Create DataFrame
etf_df = pd.DataFrame({
    'return': np.concatenate([financial_returns, energy_returns, tech_returns]),
    'sector': ['Financial']*n_etfs + ['Energy']*n_etfs + ['Technology']*n_etfs
})

print("\n Summary Statistics by Sector:")
print("-" * 40)
for sector in ['Financial', 'Energy', 'Technology']:
    data = etf_df[etf_df['sector'] == sector]['return']
    print(f"  {sector}:")
    print(f"    Mean: {data.mean():.4f} ({data.mean()*100:.2f}%)")
    print(f"    Std:  {data.std():.4f}")
    print(f"    n:    {len(data)}")

# Perform one-way ANOVA
from scipy.stats import f_oneway

f_stat, p_value = f_oneway(
    etf_df[etf_df['sector'] == 'Financial']['return'],
    etf_df[etf_df['sector'] == 'Energy']['return'],
    etf_df[etf_df['sector'] == 'Technology']['return']
)

print("\n One-Way ANOVA Results:")
print("-" * 40)
print(f"  F-Statistic: {f_stat:.4f}")
print(f"  P-Value:     {p_value:.6f}")

alpha = 0.05
if p_value < alpha:
    print(f"\n  Result: REJECT null hypothesis (p < {alpha})")
    print(f"  Interpretation: At least one sector has a significantly different mean return")
else:
    print(f"\n  Result: FAIL TO REJECT null hypothesis (p >= {alpha})")
    print(f"  Interpretation: No significant difference in mean returns across sectors")

# Visualize
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Box plots
ax = axes[0]
sns.boxplot(data=etf_df, x='sector', y='return', ax=ax)
ax.axhline(etf_df['return'].mean(), color='red', linestyle='--', 
           label=f'Overall Mean: {etf_df["return"].mean():.4f}')
ax.set_title('ETF Returns by Sector', fontsize=12)
ax.set_ylabel('10-Year Return')
ax.legend()
ax.grid(True, alpha=0.3)

# Violin plots
ax = axes[1]
sns.violinplot(data=etf_df, x='sector', y='return', ax=ax)
ax.axhline(etf_df['return'].mean(), color='red', linestyle='--', 
           label=f'Overall Mean: {etf_df["return"].mean():.4f}')
ax.set_title('Return Distributions by Sector', fontsize=12)
ax.set_ylabel('10-Year Return')
ax.legend()
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('oneway_anova_etf.png', dpi=300)
plt.show()
4.3 ANOVA Table

The ANOVA table provides a structured summary of the variance decomposition:

 
 
Source SS (Sum of Squares) df MS (Mean Square) F p-value
Between Groups SSB k-1 MSB = SSB/df_between MSB/MSW
Within Groups SSW N-k MSW = SSW/df_within    
Total SST N-1      
python
# ============= BUILDING ANOVA TABLE =============

def anova_table(df, group_col, value_col):
    """Create ANOVA table from DataFrame."""
    groups = df[group_col].unique()
    k = len(groups)
    n = len(df)
    
    # Overall mean
    grand_mean = df[value_col].mean()
    
    # Between-group sum of squares
    ss_between = 0
    for group in groups:
        group_data = df[df[group_col] == group][value_col]
        n_group = len(group_data)
        mean_group = group_data.mean()
        ss_between += n_group * (mean_group - grand_mean)**2
    
    # Within-group sum of squares
    ss_within = 0
    for group in groups:
        group_data = df[df[group_col] == group][value_col]
        mean_group = group_data.mean()
        ss_within += sum((group_data - mean_group)**2)
    
    # Degrees of freedom
    df_between = k - 1
    df_within = n - k
    df_total = n - 1
    
    # Mean squares
    ms_between = ss_between / df_between
    ms_within = ss_within / df_within
    
    # F-statistic
    f_stat = ms_between / ms_within
    
    # P-value
    p_value = 1 - stats.f.cdf(f_stat, df_between, df_within)
    
    # Create table
    table = pd.DataFrame({
        'Source': ['Between Groups', 'Within Groups', 'Total'],
        'SS': [ss_between, ss_within, ss_between + ss_within],
        'df': [df_between, df_within, df_total],
        'MS': [ms_between, ms_within, ''],
        'F': [f_stat, '', ''],
        'p-value': [p_value, '', '']
    })
    
    return table

print("\n ANOVA Table - ETF Returns by Sector")
print("-" * 60)
print(anova_table(etf_df, 'sector', 'return').to_string(index=False))

SECTION 5: POST-HOC ANALYSIS (TUKEY’S HSD)

When ANOVA rejects the null hypothesis, we know that at least one group differs, but we don’t know which groups differ. Post-hoc analysis identifies specific group differences.

Tukey’s Honestly Significant Difference (HSD) test is the most common post-hoc procedure.

python
# ============= POST-HOC ANALYSIS: TUKEY'S HSD =============

print("\n" + "="*60)
print("POST-HOC ANALYSIS: TUKEY'S HSD")
print("="*60)

# Perform Tukey's HSD
tukey = pairwise_tukeyhsd(
    endog=etf_df['return'],
    groups=etf_df['sector'],
    alpha=0.05
)

print("\n Tukey HSD Results:")
print("-" * 50)
print(tukey)

# Visualize Tukey HSD results
fig, ax = plt.subplots(figsize=(10, 6))

# Create compact letter display
tukey_summary = pd.DataFrame(data=tukey.summary())
print("\n Pairwise Comparisons:")
print("-" * 50)
for _, row in tukey_summary.iterrows():
    print(f"  {row['group1']} vs {row['group2']}:")
    print(f"    Difference: {row['meandiff']:.4f}")
    print(f"    p-adj:      {row['p-adj']:.4f}")
    if row['reject']:
        print(f"    ✓ SIGNIFICANT difference")
    else:
        print(f"    ✗ No significant difference")

# Bar chart with confidence intervals
fig, ax = plt.subplots(figsize=(10, 6))

means = etf_df.groupby('sector')['return'].mean()
std_err = etf_df.groupby('sector')['return'].sem()

ax.bar(means.index, means.values, yerr=1.96*std_err.values, 
       capsize=5, color=['blue', 'green', 'orange'], alpha=0.7)
ax.axhline(etf_df['return'].mean(), color='red', linestyle='--', 
           label=f'Overall Mean: {etf_df["return"].mean():.4f}')
ax.set_title('Mean Returns by Sector with 95% Confidence Intervals', fontsize=12)
ax.set_ylabel('10-Year Return')
ax.legend()
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('tukey_hsd_results.png', dpi=300)
plt.show()

SECTION 6: TWO-WAY ANOVA

Two-way ANOVA extends one-way ANOVA by examining two categorical independent variables and their interaction effect.

Example: Does ETF return depend on both Sector (Financial/Energy/Tech) and Market Cap (Large/Mid/Small)?

python
# ============= TWO-WAY ANOVA =============

print("\n" + "="*60)
print("TWO-WAY ANOVA")
print("="*60)

# Generate two-factor data
np.random.seed(42)
n_per_group = 20

sectors = ['Financial', 'Energy', 'Technology']
caps = ['Large', 'Mid', 'Small']

# Create interaction effects
data = []
for sector in sectors:
    for cap in caps:
        # Different means for each combination
        base_mean = {
            ('Financial', 'Large'): 0.10,
            ('Financial', 'Mid'): 0.08,
            ('Financial', 'Small'): 0.06,
            ('Energy', 'Large'): 0.07,
            ('Energy', 'Mid'): 0.05,
            ('Energy', 'Small'): 0.03,
            ('Technology', 'Large'): 0.14,
            ('Technology', 'Mid'): 0.12,
            ('Technology', 'Small'): 0.10,
        }
        mean = base_mean[(sector, cap)]
        returns = np.random.normal(mean, 0.025, n_per_group)
        for r in returns:
            data.append({'sector': sector, 'market_cap': cap, 'return': r})

two_way_df = pd.DataFrame(data)

print("\n Summary Statistics by Sector and Market Cap:")
print("-" * 50)
print(two_way_df.groupby(['sector', 'market_cap'])['return'].agg(['mean', 'std', 'count']))

# Perform two-way ANOVA using statsmodels
model = ols('return ~ C(sector) + C(market_cap) + C(sector):C(market_cap)', 
            data=two_way_df).fit()
anova_results = sm.stats.anova_lm(model, typ=2)

print("\n Two-Way ANOVA Results:")
print("-" * 50)
print(anova_results)

# Interpretation
print("\n Interpretation:")
print("-" * 50)
for variable in anova_results.index:
    if variable == 'Residual':
        continue
    p_val = anova_results.loc[variable, 'PR(>F)']
    if p_val < 0.05:
        print(f"  {variable}: SIGNIFICANT (p={p_val:.4f})")
    else:
        print(f"  {variable}: NOT significant (p={p_val:.4f})")

# Visualize interaction
fig, ax = plt.subplots(figsize=(10, 6))
sns.pointplot(data=two_way_df, x='sector', y='return', hue='market_cap', 
              markers=['o', 's', 'D'], linestyles=['-', '--', ':'], ax=ax)
ax.set_title('Interaction Plot: Sector × Market Cap', fontsize=12)
ax.set_ylabel('Mean Return')
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('twoway_anova_interaction.png', dpi=300)
plt.show()

Key Insight: If the interaction term is significant, the effect of one factor depends on the level of the other factor.


SECTION 7: NON-PARAMETRIC ALTERNATIVE – KRUSKAL-WALLIS TEST

When ANOVA assumptions are violated (particularly normality or homogeneity of variance), use the Kruskal-Wallis test – the non-parametric equivalent of one-way ANOVA.

python
# ============= KRUSKAL-WALLIS TEST =============

print("\n" + "="*60)
print("NON-PARAMETRIC ALTERNATIVE: KRUSKAL-WALLIS TEST")
print("="*60)

# Create data with non-normal distribution (skewed)
np.random.seed(42)
n = 50

# Skewed data (lognormal)
group_a = np.random.lognormal(3, 0.4, n)   # Right-skewed
group_b = np.random.lognormal(3.3, 0.4, n)
group_c = np.random.lognormal(3.6, 0.4, n)

skewed_df = pd.DataFrame({
    'value': np.concatenate([group_a, group_b, group_c]),
    'group': ['A']*n + ['B']*n + ['C']*n
})

print("\n Data Characteristics:")
print("-" * 40)
for group in ['A', 'B', 'C']:
    data = skewed_df[skewed_df['group'] == group]['value']
    print(f"  Group {group}:")
    print(f"    Mean:   {data.mean():.4f}")
    print(f"    Median: {np.median(data):.4f}")
    print(f"    Skew:   {data.skew():.4f}")

# Kruskal-Wallis test
from scipy.stats import kruskal

h_stat, p_value = kruskal(
    skewed_df[skewed_df['group'] == 'A']['value'],
    skewed_df[skewed_df['group'] == 'B']['value'],
    skewed_df[skewed_df['group'] == 'C']['value']
)

print("\n Kruskal-Wallis Test Results:")
print("-" * 40)
print(f"  H-Statistic: {h_stat:.4f}")
print(f"  P-Value:     {p_value:.6f}")

if p_value < 0.05:
    print(f"  Result: REJECT null hypothesis")
    print(f"  Interpretation: Significant difference between at least two groups")
else:
    print(f"  Result: FAIL TO REJECT null hypothesis")
    print(f"  Interpretation: No significant difference between groups")

# Visualize
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Box plots
ax = axes[0]
sns.boxplot(data=skewed_df, x='group', y='value', ax=ax)
ax.set_title('Skewed Data by Group', fontsize=12)
ax.grid(True, alpha=0.3)

# Histograms
ax = axes[1]
for group in ['A', 'B', 'C']:
    data = skewed_df[skewed_df['group'] == group]['value']
    ax.hist(data, bins=20, alpha=0.5, label=f'Group {group}')
ax.set_title('Distributions (Right-Skewed)', fontsize=12)
ax.set_xlabel('Value')
ax.set_ylabel('Frequency')
ax.legend()
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('kruskal_wallis.png', dpi=300)
plt.show()

SECTION 8: BUSINESS APPLICATIONS IN BANKING & FINANCE

8.1 Banking Use Cases
 
 
Application ANOVA Type Business Impact
Customer Segment Analysis One-way Compare transaction amounts, loan sizes, or profitability across customer segments
Product Performance One-way Compare returns across different investment products
Branch Performance One-way Compare revenue, customer satisfaction across branches
Marketing Effectiveness Two-way Analyze campaign performance by region and channel
Risk Assessment One-way Compare default rates across loan types or customer demographics
Portfolio Analysis One-way Compare returns across asset classes or sectors
Fraud Detection One-way Compare transaction characteristics between fraudulent and legitimate groups
8.2 Real-World Examples

Example 1: Corporate Failure Prediction
A study combining ANOVA with machine learning identified the top 10 financial factors predicting corporate bankruptcy, including Return on Capital, Debt Ratio, and Retained Earnings to Total Assets.

Example 2: Stock Price Analysis
ANOVA applied to stock market data can test for significant differences in average daily returns across multiple stocks, helping identify whether price movements are driven by sector-specific or group-level events.

Example 3: Deposit Interest Rates
One-way ANOVA has been used to test for significant differences in deposit interest rates across multiple national banks.

python
# ============= BANKING BUSINESS APPLICATIONS =============

print("\n" + "="*60)
print("BANKING BUSINESS APPLICATIONS OF ANOVA")
print("="*60)

# Example: Loan Default Rates by Customer Segment
np.random.seed(42)
n_customers = 100

segments = ['Premium', 'Standard', 'Basic']
default_rates = {
    'Premium': np.random.beta(1, 20, n_customers) * 0.15,
    'Standard': np.random.beta(2, 15, n_customers) * 0.15,
    'Basic': np.random.beta(3, 10, n_customers) * 0.15
}

loan_df = pd.DataFrame({
    'default_rate': np.concatenate([default_rates[s] for s in segments]),
    'segment': np.concatenate([[s]*n_customers for s in segments])
})

print("\n Loan Default Rates by Segment:")
print("-" * 40)
for segment in segments:
    data = loan_df[loan_df['segment'] == segment]['default_rate']
    print(f"  {segment}:")
    print(f"    Mean Default Rate: {data.mean():.4f} ({data.mean()*100:.2f}%)")
    print(f"    Std: {data.std():.4f}")

# ANOVA on default rates
f_stat, p_value = f_oneway(*[loan_df[loan_df['segment'] == s]['default_rate'] for s in segments])

print(f"\n ANOVA Results:")
print(f"  F-Statistic: {f_stat:.4f}")
print(f"  P-Value: {p_value:.6f}")

if p_value < 0.05:
    print(f"\n  Business Insight: Default rates differ significantly by segment")
    print(f"  Action: Adjust risk pricing and credit policies by segment")
else:
    print(f"\n  Business Insight: No significant difference in default rates")
    print(f"  Action: Standardized pricing may be appropriate")
8.3 Regulatory Considerations
 
 
Regulation ANOVA Application
Fair Lending Test for disparities in loan outcomes across protected groups
Model Validation (SR 11-7) Compare model predictions across different segments
Basel III Validate risk models across portfolios
CCAR/DFAST Compare stress test results across scenarios

SECTION 9: SUMMARY FOR THE DATA PRACTITIONER

9.1 The 1-Minute Elevator Pitch

“ANOVA (Analysis of Variance) is a statistical technique that tests whether three or more groups have different means. It works by comparing variation between groups to variation within groups. When ANOVA is significant, post-hoc tests like Tukey’s HSD identify which specific groups differ. Use one-way ANOVA for one categorical factor, two-way ANOVA for two factors plus their interaction, and Kruskal-Wallis when assumptions are violated. ANOVA is essential in banking for comparing customer segments, product performance, portfolio returns, and validating fair lending practices.”

9.2 Key Takeaways
  • ANOVA compares means across three or more groups in a single test, controlling Type I error.

  • F-statistic = MSB/MSW; larger values indicate stronger evidence of group differences.

  • Three assumptions: Normality, homogeneity of variance, and independence.

  • One-way ANOVA = one categorical factor; Two-way ANOVA = two factors + interaction.

  • Post-hoc analysis (Tukey’s HSD) identifies which specific groups differ after significant ANOVA.

  • Kruskal-Wallis is the non-parametric alternative when assumptions are violated.

  • ANOVA is widely used in finance for portfolio analysis, customer segmentation, and risk assessment.

  • Regulatory compliance requires proper statistical testing and documentation.

9.3 Recommended Next Steps
  1. Apply one-way ANOVA to compare key metrics across customer segments

  2. Use two-way ANOVA to analyze interaction effects (e.g., segment × region)

  3. Always check ANOVA assumptions before interpreting results

  4. Perform post-hoc analysis to identify specific group differences

  5. Consider Kruskal-Wallis for non-normal data

  6. Document ANOVA results for regulatory compliance


[END OF LESSON 3]