Skip to content

Commit 80e4e38

Browse files
authored
Add quality test (#174)
* Create test_quality.py * Update test_quality.py * add type
1 parent d215375 commit 80e4e38

1 file changed

Lines changed: 253 additions & 0 deletions

File tree

tests/test_quality.py

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
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

Comments
 (0)