However, real-world analytical problems frequently involve categorical variables, where numerical correlation metrics do not apply. In this context, assessing statistical association determines whether two categorical features are independent or related.
Examples include:
Is biological sex associated with brand preference?
Does educational attainment influence political party choice?
Does medical treatment type impact clinical outcome?
Contingency tables and the \(\chi^2\) test of independence identify statistical dependencies, complemented by Cramér’s V to quantify association strength.
Beyond exploratory diagnosis, these tools serve as core mechanisms for categorical data reduction, identifying redundant features and streamlining feature spaces without sacrificing informative signal.
This chapter presents these principles, illustrating how \(\chi^2\) tests evaluate association and guide feature selection and reduction decisions.
35.1 Statistical Foundations
Tip
Core Concept: Statistical Independence
Two categorical variables \(X\) and \(Y\) are statistically independent if:
If this factorization fails to hold, the variables exhibit statistical dependence.
To visualize independence dynamically, we simulate repeated dice rolls and trace the empirical joint frequency convergence toward \(1/36 \approx 0.0278\).
In practice, datasets contain observed frequencies (\(o_{ij}\)) structured in contingency tables. Under the null hypothesis of independence (\(H_0\)), expected frequencies (\(e_{ij}\)) are calculated as:
where expected frequencies reflect the frequency counts anticipated if no relationship existed between attributes. The discrepancy between observed (\(o_{ij}\)) and expected (\(e_{ij}\)) counts serves as the foundation for testing independence.
35.1.1 From Independence to Expected Frequencies
Consider a survey of \(n = 100\) respondents measuring Gender (\(X \in \{\text{Male}, \text{Female}\}\)) and Beverage Preference (\(Y \in \{\text{Coffee}, \text{Tea}\}\)).
import numpy as npimport plotly.graph_objects as gofrom plotly.subplots import make_subplotstables = {"Negligible (V ≈ 0.05)": np.array([[26, 24], [24, 26]]),"Weak (V ≈ 0.15)": np.array([[35, 15], [25, 25]]),"Moderate (V ≈ 0.30)": np.array([[40, 10], [20, 30]]),"Strong (V ≈ 0.55)": np.array([[45, 5], [10, 40]]),}colors = {"Negligible (V ≈ 0.05)": ["#d9f0f7", "#a8d8ea"],"Weak (V ≈ 0.15)": ["#c8e6c9", "#66bb6a"],"Moderate (V ≈ 0.30)": ["#fff3cd", "#ffc107"],"Strong (V ≈ 0.55)": ["#ffd6d6", "#e63946"],}fig = make_subplots( rows=1, cols=4, subplot_titles=list(tables.keys()), horizontal_spacing=0.08)for col_idx, (label, table) inenumerate(tables.items(), start=1): n = table.sum() total = table / n *100 cats_col = ["A", "B"] cats_row = ["X", "Y"]for r_idx, row_label inenumerate(cats_row): fig.add_trace(go.Bar( name=row_label, x=cats_col, y=total[r_idx], marker_color=colors[label][r_idx], text=[f"{v:.0f}%"for v in total[r_idx]], textposition="inside", showlegend=(col_idx ==1), ), row=1, col=col_idx)fig.update_layout( barmode="stack", legend_title="Category",)fig.update_yaxes(range=[0, 100], ticksuffix="%")fig.show()
35.2 Practical Application: Airline Customer Satisfaction Case Study
An airline seeks to optimize customer satisfaction drivers across 6 candidate features: Class, Travel_Type, Flight_Frequency, Delay_Status, Seat_Selection, and Purchase_Time.
Code
import numpy as npimport pandas as pdfrom scipy.stats import chi2_contingencyimport plotly.graph_objects as goimport plotly.express as pxfrom plotly.subplots import make_subplotsnp.random.seed(42)n =1000clase = np.random.choice(["Economy","Business","First"], size=n, p=[0.65, 0.25, 0.10])tipo_map = {"Economy": [0.30, 0.70],"Business": [0.85, 0.15],"First": [0.70, 0.30]}tipo = np.array([np.random.choice(["Business_Travel","Vacation"], p=tipo_map[c]) for c in clase])freq_map = {"Economy": [0.15, 0.45, 0.40],"Business": [0.65, 0.30, 0.05],"First": [0.75, 0.20, 0.05]}frecuencia = np.array([np.random.choice(["Frequent","Occasional","First_Time"], p=freq_map[c]) for c in clase])retraso_map = {"Business_Travel": [0.50, 0.30, 0.20],"Vacation": [0.45, 0.35, 0.20]}retraso = np.array([np.random.choice(["No_Delay","Minor","Severe"], p=retraso_map[t]) for t in tipo])satisf = []for c, r inzip(clase, retraso):if c =="First"and r =="No_Delay": satisf.append(np.random.choice(["Low","Medium","High"], p=[0.05,0.15,0.80]))elif c =="Business"and r !="Severe": satisf.append(np.random.choice(["Low","Medium","High"], p=[0.10,0.35,0.55]))elif c =="Economy"and r =="Severe": satisf.append(np.random.choice(["Low","Medium","High"], p=[0.60,0.30,0.10]))elif c =="Economy"and r =="No_Delay": satisf.append(np.random.choice(["Low","Medium","High"], p=[0.20,0.45,0.35]))else: satisf.append(np.random.choice(["Low","Medium","High"], p=[0.30,0.40,0.30]))satisf = np.array(satisf)asiento = np.random.choice(["Window","Aisle","Middle"], size=n, p=[0.45, 0.40, 0.15])def p_norm(): p = np.array([0.50, 0.50]) + np.random.uniform(-0.03, 0.03, 2)return p / p.sum()momento = np.array([np.random.choice(["Early","Last_Minute"], p=p_norm()) for _ inrange(n)])df = pd.DataFrame({"Class": clase, "Travel_Type": tipo, "Frequency": frecuencia,"Delay": retraso, "Satisfaction": satisf,"Seat": asiento, "Purchase_Time": momento})predictoras = ["Class","Travel_Type","Frequency","Delay","Seat","Purchase_Time"]resultados = []for var in predictoras: tabla = pd.crosstab(df[var], df["Satisfaction"]) chi2_v, p_v, dof_v, exp_v = chi2_contingency(tabla) n_tot = tabla.values.sum() min_dim =min(tabla.shape[0]-1, tabla.shape[1]-1) cramer = np.sqrt(chi2_v / (n_tot * min_dim))if cramer >0.3: fuerza ="Strong"elif cramer >0.1: fuerza ="Moderate"else: fuerza ="Weak" resultados.append({"Variable": var, "χ²": round(chi2_v,2),"p-value": round(p_v, 4), "Cramér's V": round(cramer, 3),"Strength": fuerza })res_df = pd.DataFrame(resultados).sort_values("Cramér's V", ascending=False)res_df
Variable
χ²
p-value
Cramér's V
Strength
3
Delay
108.79
0.0000
0.233
Moderate
0
Class
74.03
0.0000
0.192
Moderate
1
Travel_Type
9.79
0.0075
0.099
Weak
2
Frequency
16.60
0.0023
0.091
Weak
4
Seat
2.05
0.7265
0.032
Weak
5
Purchase_Time
0.60
0.7401
0.025
Weak
Code
colores = {"Strong": "#e63946", "Moderate": "#e9c46a", "Weak": "#2a9d8f"}fig = go.Figure()fig.add_trace(go.Bar( x=res_df["Variable"], y=res_df["Cramér's V"], marker_color=[colores[f] for f in res_df["Strength"]], text=res_df["Cramér's V"].astype(str), textposition="outside"))fig.add_hline(y=0.1, line_dash="dot", line_color="#e9c46a", annotation_text="Weak/Moderate Threshold (0.1)", annotation_position="top left")fig.add_hline(y=0.3, line_dash="dot", line_color="#e63946", annotation_text="Moderate/Strong Threshold (0.3)", annotation_position="top left")fig.update_layout( title="Cramér's V Feature Association Strength vs Satisfaction", yaxis_title="Cramér's V", yaxis_range=[0, 1.0], template="plotly_white", height=430, showlegend=False)fig.show()
35.2.1 Random Forest Model Evaluation Across Feature Subsets
Code
from sklearn.ensemble import RandomForestClassifierfrom sklearn.model_selection import cross_val_scorefrom sklearn.preprocessing import LabelEncoderdf_enc = df.copy()for col in df_enc.columns: df_enc[col] = LabelEncoder().fit_transform(df_enc[col])y = df_enc["Satisfaction"]configs = {"All (6 features)": ["Class","Travel_Type","Frequency","Delay","Seat","Purchase_Time"],"Filtered (4 features)": ["Class","Travel_Type","Frequency","Delay"],"Top 2 (2 features)": ["Class","Delay"],}scores, stds, nombres = [], [], []for nombre, cols in configs.items(): cv = cross_val_score( RandomForestClassifier(n_estimators=200, random_state=42), df_enc[cols], y, cv=5, scoring="accuracy" ) scores.append(cv.mean()) stds.append(cv.std()) nombres.append(nombre)fig = go.Figure()fig.add_trace(go.Bar( x=nombres, y=[round(s,4) for s in scores], error_y=dict(type="data", array=[round(s,4) for s in stds], visible=True), marker_color=["#457b9d","#2a9d8f","#e9c46a"], text=[f"{s:.1%}"for s in scores], textposition="outside", width=0.4))fig.update_layout( title="5-Fold Cross-Validation Accuracy across χ²-Selected Subsets", yaxis_title="Mean Accuracy ± std", yaxis_range=[0, 1.1], template="plotly_white", height=420)fig.show()
35.3 Summary
Formal Independence Hypothesis Testing: \(\chi^2\) quantifies independence deviations with Type I error control.
Effect Size Measurement: Cramér’s V measures practical association magnitude independent of sample size \(n\).
Categorical Feature Selection: Screening categorical features via \(\chi^2\) and Cramér’s V prunes non-informative noise and reduces model complexity before training.