11import numpy as np
22import pandas as pd
33from pytest import approx
4- from scipy .stats import pearsonr , chi2_contingency , ks_2samp
4+ from scipy .stats import chi2_contingency , ks_2samp , pearsonr
55from sklearn .metrics import mutual_info_score
66
77from syndiffix import Synthesizer
1212def test_quality_float_str () -> None :
1313 """Test quality of synthetic data for float-string columns with dependency."""
1414 np .random .seed (42 )
15-
15+
1616 num_rows = 1000
1717 # Create float column with normal distribution
1818 float_col = np .random .normal (50 , 15 , num_rows )
1919
2020 # Create categorical string column with dependency on float values
2121 # Higher float values more likely to be in categories 6-9
2222 str_categories = [f"cat_{ i } " for i in range (10 )]
23-
23+
2424 # Create dependency: map float values to categories with some noise
2525 normalized_float = (float_col - float_col .min ()) / (float_col .max () - float_col .min ())
26- category_indices = np .clip (
27- (normalized_float * 8 + np .random .normal (0 , 1 , num_rows )).astype (int ),
28- 0 , 9
29- )
26+ category_indices = np .clip ((normalized_float * 8 + np .random .normal (0 , 1 , num_rows )).astype (int ), 0 , 9 )
3027 str_col = [str_categories [i ] for i in category_indices ]
31-
28+
3229 df = pd .DataFrame ({"float_col" : float_col , "str_col" : str_col })
3330 # Ensure at least 20 instances of every category in str_col
3431 min_count = 20
@@ -44,103 +41,102 @@ def test_quality_float_str() -> None:
4441 df = pd .concat ([df , replicated ], ignore_index = True )
4542 else :
4643 # If no rows exist, create new rows with random float_col
47- new_rows = pd .DataFrame ({
48- "float_col" : np .random .normal (50 , 15 , min_count ),
49- "str_col" : [cat ]* min_count
50- })
44+ new_rows = pd .DataFrame (
45+ {"float_col" : np .random .normal (50 , 15 , min_count ), "str_col" : [cat ] * min_count }
46+ )
5147 df = pd .concat ([df , new_rows ], ignore_index = True )
52-
48+
5349 # Generate synthetic data
5450 df_syn = Synthesizer (df , anonymization_params = NOISELESS_PARAMS ).sample ()
55-
51+
5652 # 1. Single-column similarity tests
57-
53+
5854 # Float column: test distribution similarity using KS test
5955 ks_stat , ks_pvalue = ks_2samp (df ["float_col" ], df_syn ["float_col" ])
6056 assert ks_stat < 0.15 , f"Float column distributions too different (KS statistic: { ks_stat } )"
61-
57+
6258 # Float column: test mean and std similarity
6359 assert df_syn ["float_col" ].mean () == approx (df ["float_col" ].mean (), abs = 1.0 )
6460 assert df_syn ["float_col" ].std () == approx (df ["float_col" ].std (), abs = 0.5 )
65-
61+
6662 # String column: test category distribution similarity
6763 orig_counts = df ["str_col" ].value_counts ().sort_index ()
6864 syn_counts = df_syn ["str_col" ].value_counts ().sort_index ()
69-
65+
7066 # Ensure all original categories are present in synthetic data
7167 for cat in orig_counts .index :
7268 assert cat in syn_counts .index , f"Category { cat } missing from synthetic data"
73-
69+
7470 # Test count similarity (allowing for some variation)
7571 for cat in orig_counts .index :
7672 orig_pct = orig_counts [cat ] / len (df )
7773 syn_pct = syn_counts [cat ] / len (df_syn )
7874 assert syn_pct == approx (orig_pct , abs = 0.01 ), f"Category { cat } proportion differs too much"
79-
75+
8076 # 2. Dependency/correlation tests
81-
77+
8278 # For float-string dependency, use mutual information
8379 # Discretize float column for mutual information calculation
8480 orig_float_binned = pd .cut (df ["float_col" ], bins = 10 , labels = False )
8581 syn_float_binned = pd .cut (df_syn ["float_col" ], bins = 10 , labels = False )
86-
82+
8783 orig_str_encoded = pd .Categorical (df ["str_col" ]).codes
8884 syn_str_encoded = pd .Categorical (df_syn ["str_col" ]).codes
89-
85+
9086 orig_mi = mutual_info_score (orig_float_binned , orig_str_encoded )
9187 syn_mi = mutual_info_score (syn_float_binned , syn_str_encoded )
92-
88+
9389 assert syn_mi == approx (orig_mi , rel = 0.4 ), f"Mutual information differs too much: orig={ orig_mi } , syn={ syn_mi } "
9490
9591
9692def test_quality_float_float () -> None :
9793 """Test quality of synthetic data for float-float columns with correlation."""
9894 np .random .seed (42 )
99-
95+
10096 # Create correlated float columns
10197 x = np .random .normal (0 , 1 , 1000 )
10298 y = 0.7 * x + np .random .normal (0 , 0.5 , 1000 ) # Correlation ~0.8
103-
99+
104100 df = pd .DataFrame ({"x" : x , "y" : y })
105-
101+
106102 # Generate synthetic data
107103 df_syn = Synthesizer (df , anonymization_params = NOISELESS_PARAMS ).sample ()
108-
104+
109105 # 1. Single-column similarity tests
110-
106+
111107 # X column
112108 ks_stat_x , _ = ks_2samp (df ["x" ], df_syn ["x" ])
113109 assert ks_stat_x < 0.15 , f"X column distributions too different (KS statistic: { ks_stat_x } )"
114110 assert df_syn ["x" ].mean () == approx (df ["x" ].mean (), abs = 0.02 )
115111 assert df_syn ["x" ].std () == approx (df ["x" ].std (), abs = 0.04 )
116-
112+
117113 # Y column
118114 ks_stat_y , _ = ks_2samp (df ["y" ], df_syn ["y" ])
119115 assert ks_stat_y < 0.15 , f"Y column distributions too different (KS statistic: { ks_stat_y } )"
120116 assert df_syn ["y" ].mean () == approx (df ["y" ].mean (), abs = 0.02 )
121117 assert df_syn ["y" ].std () == approx (df ["y" ].std (), abs = 0.04 )
122-
118+
123119 # 2. Correlation tests
124-
120+
125121 orig_corr , _ = pearsonr (df ["x" ], df ["y" ])
126122 syn_corr , _ = pearsonr (df_syn ["x" ], df_syn ["y" ])
127-
123+
128124 assert syn_corr == approx (orig_corr , abs = 0.01 ), f"Correlation differs too much: orig={ orig_corr } , syn={ syn_corr } "
129125
130126
131127def test_quality_str_str () -> None :
132128 """Test quality of synthetic data for string-string columns with dependency."""
133129 np .random .seed (42 )
134-
130+
135131 # Create two categorical columns with dependency
136132 categories_a = [f"group_{ i } " for i in range (10 )]
137133 categories_b = [f"type_{ i } " for i in range (10 )]
138-
134+
139135 # Create dependency: certain groups prefer certain types
140136 group_prefs = {
141- 0 : [0 , 1 , 2 ], # group_0 prefers type_0, type_1, type_2
142- 1 : [1 , 2 , 3 ], # group_1 prefers type_1, type_2, type_3
143- 2 : [2 , 3 , 4 ], # etc.
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.
144140 3 : [3 , 4 , 5 ],
145141 4 : [4 , 5 , 6 ],
146142 5 : [5 , 6 , 7 ],
@@ -149,64 +145,64 @@ def test_quality_str_str() -> None:
149145 8 : [8 , 9 , 0 ],
150146 9 : [9 , 0 , 1 ],
151147 }
152-
148+
153149 # Generate data with dependency
154150 col_a = []
155151 col_b = []
156-
152+
157153 for _ in range (1000 ):
158154 # Choose group randomly
159155 group_idx = np .random .randint (0 , 10 )
160156 group = categories_a [group_idx ]
161-
157+
162158 # Choose type based on group preference (80% of time) or random (20% of time)
163159 if np .random .random () < 0.8 :
164160 type_idx = np .random .choice (group_prefs [group_idx ])
165161 else :
166162 type_idx = np .random .randint (0 , 10 )
167-
163+
168164 type_val = categories_b [type_idx ]
169-
165+
170166 col_a .append (group )
171167 col_b .append (type_val )
172-
168+
173169 df = pd .DataFrame ({"group" : col_a , "type" : col_b })
174-
170+
175171 # Generate synthetic data
176172 df_syn = Synthesizer (df , anonymization_params = NOISELESS_PARAMS ).sample ()
177-
173+
178174 # 1. Single-column similarity tests
179-
175+
180176 # Group column
181177 orig_group_counts = df ["group" ].value_counts ().sort_index ()
182178 syn_group_counts = df_syn ["group" ].value_counts ().sort_index ()
183-
179+
184180 for group in orig_group_counts .index :
185181 assert group in syn_group_counts .index , f"Group { group } missing from synthetic data"
186182 orig_pct = orig_group_counts [group ] / len (df )
187183 syn_pct = syn_group_counts [group ] / len (df_syn )
188184 assert syn_pct == approx (orig_pct , abs = 0.01 ), f"Group { group } proportion differs too much"
189-
185+
190186 # Type column
191187 orig_type_counts = df ["type" ].value_counts ().sort_index ()
192188 syn_type_counts = df_syn ["type" ].value_counts ().sort_index ()
193-
189+
194190 for type_val in orig_type_counts .index :
195191 assert type_val in syn_type_counts .index , f"Type { type_val } missing from synthetic data"
196192 orig_pct = orig_type_counts [type_val ] / len (df )
197193 syn_pct = syn_type_counts [type_val ] / len (df_syn )
198194 assert syn_pct == approx (orig_pct , abs = 0.005 ), f"Type { type_val } proportion differs too much"
199-
195+
200196 # 2. Dependency tests using contingency table analysis
201-
197+
202198 # Create contingency tables
203199 orig_contingency = pd .crosstab (df ["group" ], df ["type" ])
204200 syn_contingency = pd .crosstab (df_syn ["group" ], df_syn ["type" ])
205-
201+
206202 # Ensure synthetic contingency table has same shape as original
207203 assert orig_contingency .shape [0 ] <= syn_contingency .shape [0 ], "Missing groups in synthetic data"
208204 assert orig_contingency .shape [1 ] <= syn_contingency .shape [1 ], "Missing types in synthetic data"
209-
205+
210206 # Calculate Cramér's V (measure of association between categorical variables)
211207 def cramers_v (contingency_table ):
212208 chi2 , _ , _ , _ = chi2_contingency (contingency_table )
@@ -215,38 +211,37 @@ def cramers_v(contingency_table):
215211 if min_dim == 0 :
216212 return 0
217213 return np .sqrt (chi2 / (n * min_dim ))
218-
214+
219215 # Align contingency tables for comparison
220216 common_groups = set (orig_contingency .index ) & set (syn_contingency .index )
221217 common_types = set (orig_contingency .columns ) & set (syn_contingency .columns )
222-
218+
223219 orig_aligned = orig_contingency .loc [list (common_groups ), list (common_types )]
224220 syn_aligned = syn_contingency .loc [list (common_groups ), list (common_types )]
225-
221+
226222 orig_cramers_v = cramers_v (orig_aligned )
227223 syn_cramers_v = cramers_v (syn_aligned )
228-
229- assert syn_cramers_v == approx (orig_cramers_v , rel = 0.4 ), f"Cramér's V differs too much: orig={ orig_cramers_v } , syn={ syn_cramers_v } "
224+
225+ assert syn_cramers_v == approx (
226+ orig_cramers_v , rel = 0.4
227+ ), f"Cramér's V differs too much: orig={ orig_cramers_v } , syn={ syn_cramers_v } "
230228
231229
232230def test_synthetic_data_size_consistency () -> None :
233231 """Test that synthetic data has reasonable size compared to original."""
234232 np .random .seed (42 )
235-
233+
236234 # Create simple test data
237- df = pd .DataFrame ({
238- "col1" : np .random .normal (0 , 1 , 1000 ),
239- "col2" : np .random .choice (["A" , "B" , "C" ], 1000 )
240- })
241-
235+ df = pd .DataFrame ({"col1" : np .random .normal (0 , 1 , 1000 ), "col2" : np .random .choice (["A" , "B" , "C" ], 1000 )})
236+
242237 df_syn = Synthesizer (df , anonymization_params = NOISELESS_PARAMS ).sample ()
243-
238+
244239 # Synthetic data should have reasonable size (within 50% of original)
245240 assert len (df_syn ) > 0.5 * len (df ), "Synthetic data too small"
246241 assert len (df_syn ) < 2.0 * len (df ), "Synthetic data too large"
247-
242+
248243 # Should have same number of columns
249244 assert len (df_syn .columns ) == len (df .columns ), "Different number of columns"
250-
245+
251246 # Should have same column names
252247 assert list (df_syn .columns ) == list (df .columns ), "Different column names"
0 commit comments