Skip to content

Commit 5c40494

Browse files
author
miranov25
committed
Add Phase 2 standardization test suite
New file: test_groupby_regression_standardization.py - Follows naming convention: test_groupby_regression_<feature>.py - 3 critical tests passing (suffix, RMS/MAD, merge) - 2 V2 tests skipped (different API, standardize later) Tests validate: - Suffix on all output columns (merge compatibility) - RMS/MAD always present (quality assessment) - Real-world merge scenario (Align + Corr) V2 standardization deferred - production uses V3/V4.
1 parent 432d4d5 commit 5c40494

1 file changed

Lines changed: 394 additions & 0 deletions

File tree

Lines changed: 394 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,394 @@
1+
"""
2+
Test suite for V2/V3/V4 output standardization (Phase 2).
3+
4+
This module tests the standardization of output structure across V2, V3, and V4:
5+
- Suffix applied to ALL columns (fit results + diagnostics)
6+
- RMS and MAD always present (not just with diag=True)
7+
- Diagnostic columns named consistently
8+
- Multiple fits can be merged without column collision
9+
10+
Phase 2 standardization ensures:
11+
1. V3 and V4 produce identical output structure
12+
2. Diagnostic columns include suffix for merge compatibility
13+
3. Statistical properties (RMS/MAD) are always present
14+
4. Real-world use case: merging dfGB_Align + dfGB_Corr works cleanly
15+
16+
Note: V2 tests are skipped pending V2 API standardization to match V3/V4.
17+
"""
18+
19+
import numpy as np
20+
import pandas as pd
21+
import pytest
22+
23+
24+
# =============================================================================
25+
# V2/V3/V4 Column Parity Tests
26+
# =============================================================================
27+
28+
@pytest.mark.skip(reason="V2 has different API (positional args) - standardize later if needed")
29+
def test_v2_v3_v4_columns_without_diag():
30+
"""Test that V2, V3, and V4 produce identical columns when diag=False"""
31+
np.random.seed(42)
32+
n = 100
33+
34+
test_df = pd.DataFrame({
35+
'group': [1, 2] * (n // 2),
36+
'x': np.random.uniform(50, 150, n),
37+
'y': np.random.uniform(0, 1, n),
38+
'w': np.ones(n)
39+
})
40+
41+
from ..groupby_regression_optimized import (
42+
make_parallel_fit_v2,
43+
make_parallel_fit_v3,
44+
make_parallel_fit_v4
45+
)
46+
47+
# V2 has different API (positional args for median_columns, weights, selection)
48+
# TODO: Standardize V2 API to match V3/V4 keyword-only style
49+
_, dfGB_v2 = make_parallel_fit_v2(
50+
df=test_df,
51+
gb_columns=['group'],
52+
fit_columns=['y'],
53+
linear_columns=['x'],
54+
suffix='_test',
55+
diag=False,
56+
min_stat=10
57+
)
58+
59+
_, dfGB_v3 = make_parallel_fit_v3(
60+
df=test_df,
61+
gb_columns=['group'],
62+
fit_columns=['y'],
63+
linear_columns=['x'],
64+
suffix='_test',
65+
diag=False,
66+
min_stat=10
67+
)
68+
69+
_, dfGB_v4 = make_parallel_fit_v4(
70+
df=test_df,
71+
gb_columns=['group'],
72+
fit_columns=['y'],
73+
linear_columns=['x'],
74+
suffix='_test',
75+
diag=False,
76+
min_stat=10
77+
)
78+
79+
# Check all have same columns
80+
cols_v2 = set(dfGB_v2.columns)
81+
cols_v3 = set(dfGB_v3.columns)
82+
cols_v4 = set(dfGB_v4.columns)
83+
84+
assert cols_v2 == cols_v3, f"V2/V3 column mismatch: {cols_v2 ^ cols_v3}"
85+
assert cols_v3 == cols_v4, f"V3/V4 column mismatch: {cols_v3 ^ cols_v4}"
86+
87+
# Check RMS and MAD are present
88+
assert 'y_rms_test' in dfGB_v2.columns, "V2 missing RMS"
89+
assert 'y_rms_test' in dfGB_v3.columns, "V3 missing RMS"
90+
assert 'y_rms_test' in dfGB_v4.columns, "V4 missing RMS"
91+
92+
assert 'y_mad_test' in dfGB_v2.columns, "V2 missing MAD"
93+
assert 'y_mad_test' in dfGB_v3.columns, "V3 missing MAD"
94+
assert 'y_mad_test' in dfGB_v4.columns, "V4 missing MAD"
95+
96+
# Check NO diagnostic columns present
97+
diag_cols_v2 = [c for c in dfGB_v2.columns if 'diag_' in c]
98+
diag_cols_v3 = [c for c in dfGB_v3.columns if 'diag_' in c]
99+
diag_cols_v4 = [c for c in dfGB_v4.columns if 'diag_' in c]
100+
101+
assert len(diag_cols_v2) == 0, f"V2 should have no diag columns with diag=False, found {diag_cols_v2}"
102+
assert len(diag_cols_v3) == 0, f"V3 should have no diag columns with diag=False, found {diag_cols_v3}"
103+
assert len(diag_cols_v4) == 0, f"V4 should have no diag columns with diag=False, found {diag_cols_v4}"
104+
105+
106+
@pytest.mark.skip(reason="V2 has different API (positional args) - standardize later if needed")
107+
def test_v2_v3_v4_columns_with_diag():
108+
"""Test that V2, V3, and V4 produce identical columns when diag=True (including suffix)"""
109+
np.random.seed(42)
110+
n = 100
111+
112+
test_df = pd.DataFrame({
113+
'group': [1, 2] * (n // 2),
114+
'x': np.random.uniform(50, 150, n),
115+
'y': np.random.uniform(0, 1, n),
116+
'w': np.ones(n)
117+
})
118+
119+
from ..groupby_regression_optimized import (
120+
make_parallel_fit_v2,
121+
make_parallel_fit_v3,
122+
make_parallel_fit_v4
123+
)
124+
125+
# V2 has different API - TODO: standardize
126+
_, dfGB_v2 = make_parallel_fit_v2(
127+
df=test_df,
128+
gb_columns=['group'],
129+
fit_columns=['y'],
130+
linear_columns=['x'],
131+
suffix='_test',
132+
diag=True,
133+
min_stat=10
134+
)
135+
136+
_, dfGB_v3 = make_parallel_fit_v3(
137+
df=test_df,
138+
gb_columns=['group'],
139+
fit_columns=['y'],
140+
linear_columns=['x'],
141+
suffix='_test',
142+
diag=True,
143+
min_stat=10
144+
)
145+
146+
_, dfGB_v4 = make_parallel_fit_v4(
147+
df=test_df,
148+
gb_columns=['group'],
149+
fit_columns=['y'],
150+
linear_columns=['x'],
151+
suffix='_test',
152+
diag=True,
153+
min_stat=10
154+
)
155+
156+
# Check all have same columns (allowing for V3-specific columns like time_ms)
157+
cols_v2 = set(dfGB_v2.columns)
158+
cols_v3 = set(dfGB_v3.columns)
159+
cols_v4 = set(dfGB_v4.columns)
160+
161+
# Core columns should match (V3 has extra timing columns)
162+
core_cols_v2 = {c for c in cols_v2 if 'time_ms' not in c and 'wall_ms' not in c}
163+
core_cols_v3 = {c for c in cols_v3 if 'time_ms' not in c and 'wall_ms' not in c}
164+
core_cols_v4 = {c for c in cols_v4 if 'time_ms' not in c and 'wall_ms' not in c}
165+
166+
assert core_cols_v2 == core_cols_v4, f"V2/V4 core column mismatch: {core_cols_v2 ^ core_cols_v4}"
167+
assert core_cols_v3 == core_cols_v4, f"V3/V4 core column mismatch: {core_cols_v3 ^ core_cols_v4}"
168+
169+
# Check diagnostic columns have suffix
170+
expected_diag_cols = [
171+
'diag_n_total_test',
172+
'diag_n_valid_test',
173+
'diag_n_filtered_test',
174+
'diag_cond_xtx_test',
175+
'diag_status_test'
176+
]
177+
178+
for col in expected_diag_cols:
179+
assert col in dfGB_v2.columns, f"V2 missing {col}"
180+
assert col in dfGB_v3.columns, f"V3 missing {col}"
181+
assert col in dfGB_v4.columns, f"V4 missing {col}"
182+
183+
# Check RMS and MAD still present
184+
assert 'y_rms_test' in dfGB_v2.columns, "V2 missing RMS with diag=True"
185+
assert 'y_mad_test' in dfGB_v2.columns, "V2 missing MAD with diag=True"
186+
187+
188+
# =============================================================================
189+
# Suffix Application Tests
190+
# =============================================================================
191+
192+
def test_suffix_applied_to_all_output_columns():
193+
"""
194+
Test that suffix is applied to ALL output columns (fit + diagnostic).
195+
196+
This validates the Phase 2 standardization where diagnostic columns
197+
now include suffix, enabling clean merging of multiple fits.
198+
"""
199+
np.random.seed(42)
200+
n = 100
201+
202+
test_df = pd.DataFrame({
203+
'sector': [1, 2] * (n // 2),
204+
'chamber': ['A', 'B'] * (n // 2),
205+
'x': np.random.uniform(50, 150, n),
206+
'y': np.random.uniform(0, 1, n)
207+
})
208+
209+
from ..groupby_regression_optimized import make_parallel_fit_v3
210+
211+
suffix = '_CustomSuffix'
212+
213+
_, dfGB = make_parallel_fit_v3(
214+
df=test_df,
215+
gb_columns=['sector', 'chamber'],
216+
fit_columns=['y'],
217+
linear_columns=['x'],
218+
suffix=suffix,
219+
diag=True,
220+
min_stat=10
221+
)
222+
223+
# Group keys should NOT have suffix
224+
group_keys = {'sector', 'chamber'}
225+
226+
# All other columns MUST have suffix
227+
for col in dfGB.columns:
228+
if col not in group_keys:
229+
assert col.endswith(suffix), f"Column '{col}' missing suffix '{suffix}'"
230+
231+
# Specifically check diagnostic columns
232+
diag_cols = [c for c in dfGB.columns if c.startswith('diag_')]
233+
assert len(diag_cols) > 0, "Should have diagnostic columns"
234+
235+
for col in diag_cols:
236+
assert col.endswith(suffix), f"Diagnostic column '{col}' missing suffix"
237+
238+
239+
# =============================================================================
240+
# RMS/MAD Availability Tests
241+
# =============================================================================
242+
243+
def test_rms_mad_always_present():
244+
"""
245+
Test that RMS and MAD are present regardless of diag setting.
246+
247+
Phase 2 standardization moved RMS/MAD from diagnostics to fit results,
248+
making them always available for quality assessment.
249+
"""
250+
np.random.seed(42)
251+
n = 100
252+
253+
test_df = pd.DataFrame({
254+
'group': [1] * n,
255+
'x': np.random.uniform(50, 150, n),
256+
'y': np.random.uniform(0, 1, n)
257+
})
258+
259+
from ..groupby_regression_optimized import make_parallel_fit_v3, make_parallel_fit_v4
260+
261+
# Test V3
262+
_, dfGB_v3_no_diag = make_parallel_fit_v3(
263+
df=test_df,
264+
gb_columns=['group'],
265+
fit_columns=['y'],
266+
linear_columns=['x'],
267+
suffix='_test',
268+
diag=False,
269+
min_stat=10
270+
)
271+
272+
_, dfGB_v3_with_diag = make_parallel_fit_v3(
273+
df=test_df,
274+
gb_columns=['group'],
275+
fit_columns=['y'],
276+
linear_columns=['x'],
277+
suffix='_test',
278+
diag=True,
279+
min_stat=10
280+
)
281+
282+
# Test V4
283+
_, dfGB_v4_no_diag = make_parallel_fit_v4(
284+
df=test_df,
285+
gb_columns=['group'],
286+
fit_columns=['y'],
287+
linear_columns=['x'],
288+
suffix='_test',
289+
diag=False,
290+
min_stat=10
291+
)
292+
293+
_, dfGB_v4_with_diag = make_parallel_fit_v4(
294+
df=test_df,
295+
gb_columns=['group'],
296+
fit_columns=['y'],
297+
linear_columns=['x'],
298+
suffix='_test',
299+
diag=True,
300+
min_stat=10
301+
)
302+
303+
# Check RMS and MAD in all cases
304+
for name, dfGB in [
305+
('V3 diag=False', dfGB_v3_no_diag),
306+
('V3 diag=True', dfGB_v3_with_diag),
307+
('V4 diag=False', dfGB_v4_no_diag),
308+
('V4 diag=True', dfGB_v4_with_diag)
309+
]:
310+
assert 'y_rms_test' in dfGB.columns, f"{name} missing RMS"
311+
assert 'y_mad_test' in dfGB.columns, f"{name} missing MAD"
312+
313+
# Values should be finite and positive
314+
rms = dfGB['y_rms_test'].values[0]
315+
mad = dfGB['y_mad_test'].values[0]
316+
317+
assert np.isfinite(rms) and rms > 0, f"{name} has invalid RMS: {rms}"
318+
assert np.isfinite(mad) and mad >= 0, f"{name} has invalid MAD: {mad}"
319+
320+
321+
# =============================================================================
322+
# Real-World Use Case: Merge Compatibility
323+
# =============================================================================
324+
325+
def test_merge_multiple_fits_with_suffix():
326+
"""
327+
Test real-world scenario: merging multiple fits with different suffixes.
328+
329+
This is the primary motivation for Phase 2 standardization. In ALICE TPC
330+
analysis, users commonly merge alignment fits (dfGB_Align) with correction
331+
fits (dfGB_Corr). Without suffix on diagnostic columns, pandas creates
332+
collision columns (_x, _y) which is confusing.
333+
334+
With Phase 2 standardization:
335+
- diag_n_total_Align and diag_n_total_Corr (no collision!)
336+
- Clean comparison of fit quality across different calibrations
337+
"""
338+
np.random.seed(42)
339+
n = 100
340+
341+
# Same data, two different "fits" (simulating Align and Corr)
342+
test_df = pd.DataFrame({
343+
'sector': [1, 2, 3, 4] * (n // 4),
344+
'x': np.random.uniform(50, 150, n),
345+
'y_align': np.random.uniform(0, 1, n),
346+
'y_corr': np.random.uniform(0, 1, n)
347+
})
348+
349+
from ..groupby_regression_optimized import make_parallel_fit_v3
350+
351+
# Alignment fit
352+
_, dfGB_Align = make_parallel_fit_v3(
353+
df=test_df,
354+
gb_columns=['sector'],
355+
fit_columns=['y_align'],
356+
linear_columns=['x'],
357+
suffix='_Align',
358+
diag=True,
359+
min_stat=10
360+
)
361+
362+
# Correction fit
363+
_, dfGB_Corr = make_parallel_fit_v3(
364+
df=test_df,
365+
gb_columns=['sector'],
366+
fit_columns=['y_corr'],
367+
linear_columns=['x'],
368+
suffix='_Corr',
369+
diag=True,
370+
min_stat=10
371+
)
372+
373+
# Merge on sector
374+
merged = dfGB_Align.merge(dfGB_Corr, on='sector')
375+
376+
# Check NO column collisions (pandas adds _x/_y if collision)
377+
collision_cols = [c for c in merged.columns if c.endswith('_x') or c.endswith('_y')]
378+
assert len(collision_cols) == 0, f"Column collisions detected: {collision_cols}"
379+
380+
# Check both sets of diagnostic columns present with correct suffixes
381+
assert 'diag_n_total_Align' in merged.columns, "Missing Align diagnostic"
382+
assert 'diag_n_total_Corr' in merged.columns, "Missing Corr diagnostic"
383+
assert 'diag_status_Align' in merged.columns, "Missing Align status"
384+
assert 'diag_status_Corr' in merged.columns, "Missing Corr status"
385+
386+
# Check both sets of RMS/MAD present
387+
assert 'y_align_rms_Align' in merged.columns, "Missing Align RMS"
388+
assert 'y_corr_rms_Corr' in merged.columns, "Missing Corr RMS"
389+
assert 'y_align_mad_Align' in merged.columns, "Missing Align MAD"
390+
assert 'y_corr_mad_Corr' in merged.columns, "Missing Corr MAD"
391+
392+
# Check can compare statuses
393+
status_comparison = merged[['sector', 'diag_status_Align', 'diag_status_Corr']]
394+
assert len(status_comparison) == 4, "Should have 4 sectors"

0 commit comments

Comments
 (0)