|
| 1 | +import numpy as np |
| 2 | +import pandas as pd |
| 3 | +from pytest import approx |
| 4 | +from scipy.stats import chi2_contingency, ks_2samp, pearsonr |
| 5 | +from sklearn.metrics import mutual_info_score |
| 6 | + |
| 7 | +from syndiffix import Synthesizer |
| 8 | + |
| 9 | +from .conftest import * |
| 10 | + |
| 11 | + |
| 12 | +def test_quality_float_str() -> None: |
| 13 | + """Test quality of synthetic data for float-string columns with dependency.""" |
| 14 | + np.random.seed(42) |
| 15 | + |
| 16 | + num_rows = 1000 |
| 17 | + # Create float column with normal distribution |
| 18 | + float_col = np.random.normal(50, 15, num_rows) |
| 19 | + |
| 20 | + # Create categorical string column with dependency on float values |
| 21 | + # Higher float values more likely to be in categories 6-9 |
| 22 | + str_categories = [f"cat_{i}" for i in range(10)] |
| 23 | + |
| 24 | + # Create dependency: map float values to categories with some noise |
| 25 | + normalized_float = (float_col - float_col.min()) / (float_col.max() - float_col.min()) |
| 26 | + category_indices = np.clip((normalized_float * 8 + np.random.normal(0, 1, num_rows)).astype(int), 0, 9) |
| 27 | + str_col = [str_categories[i] for i in category_indices] |
| 28 | + |
| 29 | + df = pd.DataFrame({"float_col": float_col, "str_col": str_col}) |
| 30 | + # Ensure at least 20 instances of every category in str_col |
| 31 | + min_count = 20 |
| 32 | + for cat in str_categories: |
| 33 | + count = (df["str_col"] == cat).sum() |
| 34 | + if count < min_count: |
| 35 | + # Find all rows with this category |
| 36 | + rows = df[df["str_col"] == cat] |
| 37 | + # If there are any, replicate random rows to reach min_count |
| 38 | + if not rows.empty: |
| 39 | + n_to_add = min_count - count |
| 40 | + replicated = rows.sample(n=n_to_add, replace=True, random_state=42) |
| 41 | + df = pd.concat([df, replicated], ignore_index=True) |
| 42 | + else: |
| 43 | + # If no rows exist, create new rows with random float_col |
| 44 | + new_rows = pd.DataFrame( |
| 45 | + {"float_col": np.random.normal(50, 15, min_count), "str_col": [cat] * min_count} |
| 46 | + ) |
| 47 | + df = pd.concat([df, new_rows], ignore_index=True) |
| 48 | + |
| 49 | + # Generate synthetic data |
| 50 | + df_syn = Synthesizer(df, anonymization_params=NOISELESS_PARAMS).sample() |
| 51 | + |
| 52 | + # 1. Single-column similarity tests |
| 53 | + |
| 54 | + # Float column: test distribution similarity using KS test |
| 55 | + ks_stat, ks_pvalue = ks_2samp(df["float_col"], df_syn["float_col"]) |
| 56 | + assert ks_stat < 0.15, f"Float column distributions too different (KS statistic: {ks_stat})" |
| 57 | + |
| 58 | + # Float column: test mean and std similarity |
| 59 | + assert df_syn["float_col"].mean() == approx(df["float_col"].mean(), abs=1.0) |
| 60 | + assert df_syn["float_col"].std() == approx(df["float_col"].std(), abs=0.5) |
| 61 | + |
| 62 | + # String column: test category distribution similarity |
| 63 | + orig_counts = df["str_col"].value_counts().sort_index() |
| 64 | + syn_counts = df_syn["str_col"].value_counts().sort_index() |
| 65 | + |
| 66 | + # Ensure all original categories are present in synthetic data |
| 67 | + for cat in orig_counts.index: |
| 68 | + assert cat in syn_counts.index, f"Category {cat} missing from synthetic data" |
| 69 | + |
| 70 | + # Test count similarity (allowing for some variation) |
| 71 | + for cat in orig_counts.index: |
| 72 | + orig_pct = orig_counts[cat] / len(df) |
| 73 | + syn_pct = syn_counts[cat] / len(df_syn) |
| 74 | + assert syn_pct == approx(orig_pct, abs=0.01), f"Category {cat} proportion differs too much" |
| 75 | + |
| 76 | + # 2. Dependency/correlation tests |
| 77 | + |
| 78 | + # For float-string dependency, use mutual information |
| 79 | + # Discretize float column for mutual information calculation |
| 80 | + orig_float_binned = pd.cut(df["float_col"], bins=10, labels=False) |
| 81 | + syn_float_binned = pd.cut(df_syn["float_col"], bins=10, labels=False) |
| 82 | + |
| 83 | + orig_str_encoded = pd.Categorical(df["str_col"]).codes |
| 84 | + syn_str_encoded = pd.Categorical(df_syn["str_col"]).codes |
| 85 | + |
| 86 | + orig_mi = mutual_info_score(orig_float_binned, orig_str_encoded) |
| 87 | + syn_mi = mutual_info_score(syn_float_binned, syn_str_encoded) |
| 88 | + |
| 89 | + assert syn_mi == approx(orig_mi, rel=0.4), f"Mutual information differs too much: orig={orig_mi}, syn={syn_mi}" |
| 90 | + |
| 91 | + |
| 92 | +def test_quality_float_float() -> None: |
| 93 | + """Test quality of synthetic data for float-float columns with correlation.""" |
| 94 | + np.random.seed(42) |
| 95 | + |
| 96 | + # Create correlated float columns |
| 97 | + x = np.random.normal(0, 1, 1000) |
| 98 | + y = 0.7 * x + np.random.normal(0, 0.5, 1000) # Correlation ~0.8 |
| 99 | + |
| 100 | + df = pd.DataFrame({"x": x, "y": y}) |
| 101 | + |
| 102 | + # Generate synthetic data |
| 103 | + df_syn = Synthesizer(df, anonymization_params=NOISELESS_PARAMS).sample() |
| 104 | + |
| 105 | + # 1. Single-column similarity tests |
| 106 | + |
| 107 | + # X column |
| 108 | + ks_stat_x, _ = ks_2samp(df["x"], df_syn["x"]) |
| 109 | + assert ks_stat_x < 0.15, f"X column distributions too different (KS statistic: {ks_stat_x})" |
| 110 | + assert df_syn["x"].mean() == approx(df["x"].mean(), abs=0.02) |
| 111 | + assert df_syn["x"].std() == approx(df["x"].std(), abs=0.04) |
| 112 | + |
| 113 | + # Y column |
| 114 | + ks_stat_y, _ = ks_2samp(df["y"], df_syn["y"]) |
| 115 | + assert ks_stat_y < 0.15, f"Y column distributions too different (KS statistic: {ks_stat_y})" |
| 116 | + assert df_syn["y"].mean() == approx(df["y"].mean(), abs=0.02) |
| 117 | + assert df_syn["y"].std() == approx(df["y"].std(), abs=0.04) |
| 118 | + |
| 119 | + # 2. Correlation tests |
| 120 | + |
| 121 | + orig_corr, _ = pearsonr(df["x"], df["y"]) |
| 122 | + syn_corr, _ = pearsonr(df_syn["x"], df_syn["y"]) |
| 123 | + |
| 124 | + assert syn_corr == approx(orig_corr, abs=0.01), f"Correlation differs too much: orig={orig_corr}, syn={syn_corr}" |
| 125 | + |
| 126 | + |
| 127 | +def test_quality_str_str() -> None: |
| 128 | + """Test quality of synthetic data for string-string columns with dependency.""" |
| 129 | + np.random.seed(42) |
| 130 | + |
| 131 | + # Create two categorical columns with dependency |
| 132 | + categories_a = [f"group_{i}" for i in range(10)] |
| 133 | + categories_b = [f"type_{i}" for i in range(10)] |
| 134 | + |
| 135 | + # Create dependency: certain groups prefer certain types |
| 136 | + group_prefs = { |
| 137 | + 0: [0, 1, 2], # group_0 prefers type_0, type_1, type_2 |
| 138 | + 1: [1, 2, 3], # group_1 prefers type_1, type_2, type_3 |
| 139 | + 2: [2, 3, 4], # etc. |
| 140 | + 3: [3, 4, 5], |
| 141 | + 4: [4, 5, 6], |
| 142 | + 5: [5, 6, 7], |
| 143 | + 6: [6, 7, 8], |
| 144 | + 7: [7, 8, 9], |
| 145 | + 8: [8, 9, 0], |
| 146 | + 9: [9, 0, 1], |
| 147 | + } |
| 148 | + |
| 149 | + # Generate data with dependency |
| 150 | + col_a = [] |
| 151 | + col_b = [] |
| 152 | + |
| 153 | + for _ in range(1000): |
| 154 | + # Choose group randomly |
| 155 | + group_idx = np.random.randint(0, 10) |
| 156 | + group = categories_a[group_idx] |
| 157 | + |
| 158 | + # Choose type based on group preference (80% of time) or random (20% of time) |
| 159 | + if np.random.random() < 0.8: |
| 160 | + type_idx = np.random.choice(group_prefs[group_idx]) |
| 161 | + else: |
| 162 | + type_idx = np.random.randint(0, 10) |
| 163 | + |
| 164 | + type_val = categories_b[type_idx] |
| 165 | + |
| 166 | + col_a.append(group) |
| 167 | + col_b.append(type_val) |
| 168 | + |
| 169 | + df = pd.DataFrame({"group": col_a, "type": col_b}) |
| 170 | + |
| 171 | + # Generate synthetic data |
| 172 | + df_syn = Synthesizer(df, anonymization_params=NOISELESS_PARAMS).sample() |
| 173 | + |
| 174 | + # 1. Single-column similarity tests |
| 175 | + |
| 176 | + # Group column |
| 177 | + orig_group_counts = df["group"].value_counts().sort_index() |
| 178 | + syn_group_counts = df_syn["group"].value_counts().sort_index() |
| 179 | + |
| 180 | + for group in orig_group_counts.index: |
| 181 | + assert group in syn_group_counts.index, f"Group {group} missing from synthetic data" |
| 182 | + orig_pct = orig_group_counts[group] / len(df) |
| 183 | + syn_pct = syn_group_counts[group] / len(df_syn) |
| 184 | + assert syn_pct == approx(orig_pct, abs=0.01), f"Group {group} proportion differs too much" |
| 185 | + |
| 186 | + # Type column |
| 187 | + orig_type_counts = df["type"].value_counts().sort_index() |
| 188 | + syn_type_counts = df_syn["type"].value_counts().sort_index() |
| 189 | + |
| 190 | + for type_val in orig_type_counts.index: |
| 191 | + assert type_val in syn_type_counts.index, f"Type {type_val} missing from synthetic data" |
| 192 | + orig_pct = orig_type_counts[type_val] / len(df) |
| 193 | + syn_pct = syn_type_counts[type_val] / len(df_syn) |
| 194 | + assert syn_pct == approx(orig_pct, abs=0.005), f"Type {type_val} proportion differs too much" |
| 195 | + |
| 196 | + # 2. Dependency tests using contingency table analysis |
| 197 | + |
| 198 | + # Create contingency tables |
| 199 | + orig_contingency = pd.crosstab(df["group"], df["type"]) |
| 200 | + syn_contingency = pd.crosstab(df_syn["group"], df_syn["type"]) |
| 201 | + |
| 202 | + # Ensure synthetic contingency table has same shape as original |
| 203 | + assert orig_contingency.shape[0] <= syn_contingency.shape[0], "Missing groups in synthetic data" |
| 204 | + assert orig_contingency.shape[1] <= syn_contingency.shape[1], "Missing types in synthetic data" |
| 205 | + |
| 206 | + # Calculate Cramér's V (measure of association between categorical variables) |
| 207 | + def cramers_v(contingency_table: pd.DataFrame) -> float: |
| 208 | + chi2, _, _, _ = chi2_contingency(contingency_table) |
| 209 | + n = contingency_table.sum().sum() |
| 210 | + min_dim = min(contingency_table.shape) - 1 |
| 211 | + if min_dim == 0: |
| 212 | + return 0 |
| 213 | + return np.sqrt(chi2 / (n * min_dim)) |
| 214 | + |
| 215 | + # Align contingency tables for comparison |
| 216 | + common_groups = set(orig_contingency.index) & set(syn_contingency.index) |
| 217 | + common_types = set(orig_contingency.columns) & set(syn_contingency.columns) |
| 218 | + |
| 219 | + orig_aligned = orig_contingency.loc[list(common_groups), list(common_types)] |
| 220 | + syn_aligned = syn_contingency.loc[list(common_groups), list(common_types)] |
| 221 | + |
| 222 | + # Ensure arguments are always DataFrames |
| 223 | + if isinstance(orig_aligned, pd.Series): |
| 224 | + orig_aligned = orig_aligned.to_frame().T |
| 225 | + if isinstance(syn_aligned, pd.Series): |
| 226 | + syn_aligned = syn_aligned.to_frame().T |
| 227 | + |
| 228 | + orig_cramers_v = cramers_v(orig_aligned) |
| 229 | + syn_cramers_v = cramers_v(syn_aligned) |
| 230 | + |
| 231 | + assert syn_cramers_v == approx( |
| 232 | + orig_cramers_v, rel=0.4 |
| 233 | + ), f"Cramér's V differs too much: orig={orig_cramers_v}, syn={syn_cramers_v}" |
| 234 | + |
| 235 | + |
| 236 | +def test_synthetic_data_size_consistency() -> None: |
| 237 | + """Test that synthetic data has reasonable size compared to original.""" |
| 238 | + np.random.seed(42) |
| 239 | + |
| 240 | + # Create simple test data |
| 241 | + df = pd.DataFrame({"col1": np.random.normal(0, 1, 1000), "col2": np.random.choice(["A", "B", "C"], 1000)}) |
| 242 | + |
| 243 | + df_syn = Synthesizer(df, anonymization_params=NOISELESS_PARAMS).sample() |
| 244 | + |
| 245 | + # Synthetic data should have reasonable size (within 50% of original) |
| 246 | + assert len(df_syn) > 0.5 * len(df), "Synthetic data too small" |
| 247 | + assert len(df_syn) < 2.0 * len(df), "Synthetic data too large" |
| 248 | + |
| 249 | + # Should have same number of columns |
| 250 | + assert len(df_syn.columns) == len(df.columns), "Different number of columns" |
| 251 | + |
| 252 | + # Should have same column names |
| 253 | + assert list(df_syn.columns) == list(df.columns), "Different column names" |
0 commit comments