Skip to content

Commit a37378d

Browse files
cycle pooling
1 parent ed8026f commit a37378d

3 files changed

Lines changed: 219 additions & 4 deletions

File tree

app.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
load_cycle_variable_info, load_crosswalk, load_categories, merge_data
1717
)
1818
from src.data.processor import create_age_groups, apply_region_filter, apply_inclusion_flag_filters
19-
from src.data.harmonizer import prepare_pooled_variable
19+
from src.data.harmonizer import filter_poolable_variables, prepare_pooled_variable
2020
from src.analysis.bootstrap import (
2121
run_bootstrap_analysis_for_all_values,
2222
run_cycle_pooled_analysis,
@@ -90,6 +90,7 @@ def main():
9090
analysis_mode = create_analysis_mode_selector()
9191
is_pooling_mode = analysis_mode == "Cycle Pooling"
9292
is_multi_cycle_mode = analysis_mode in {"Multi-Cycle Trends", "Cycle Pooling"}
93+
pooling_compatibility_issues = {}
9394

9495
# Handle mode switching with warning if user has active work
9596
previous_mode = get_session_state('analysis_mode')
@@ -203,6 +204,13 @@ def main():
203204

204205
# Load cycle variable info for all selected cycles (for labels)
205206
cycle_var_info_dict = {cycle: load_cycle_variable_info(cycle) for cycle in selected_cycles}
207+
if is_pooling_mode:
208+
available_harmonized_vars, pooling_compatibility_issues = filter_poolable_variables(
209+
available_harmonized_vars,
210+
selected_cycles,
211+
crosswalk,
212+
cycle_var_info_dict,
213+
)
206214

207215
merged_desc_dict = merge_descriptions(json_desc_dict, desc_dict)
208216

@@ -215,6 +223,20 @@ def main():
215223

216224
display_data_metrics(data)
217225
st.info(f"📊 Data harmonized and combined from cycles: {cycles_str}")
226+
if is_pooling_mode and pooling_compatibility_issues:
227+
st.warning(
228+
f"Excluded {len(pooling_compatibility_issues):,} common column(s) "
229+
"from pooling because their meanings or category structures "
230+
"are not equivalent across all selected cycles."
231+
)
232+
with st.expander("Review variables excluded from pooling"):
233+
compatibility_df = pd.DataFrame(
234+
[
235+
{"Variable": variable, "Reason": reason}
236+
for variable, reason in pooling_compatibility_issues.items()
237+
]
238+
)
239+
st.dataframe(compatibility_df, use_container_width=True)
218240

219241
cycle = cycles_str
220242
use_harmonized = True
@@ -338,7 +360,7 @@ def main():
338360
# Get available harmonized variables
339361
if is_multi_cycle_mode:
340362
# For multi-cycle, use common harmonized variables
341-
if not available_harmonized_vars and crosswalk:
363+
if not available_harmonized_vars and crosswalk and not is_pooling_mode:
342364
from src.data.harmonizer import get_common_harmonized_vars
343365
data_dict_for_check = {}
344366
for cycle_year in selected_cycles:
@@ -375,7 +397,7 @@ def main():
375397
use_harmonized = True
376398
st.info(f"Multi-cycle mode: Using {len(available_harmonized_vars)} harmonized variables available across all selected cycles")
377399

378-
if use_harmonized and available_harmonized_vars:
400+
if use_harmonized:
379401
variable_options = available_harmonized_vars
380402
variable_labels = {}
381403
for var in variable_options:

src/data/harmonizer.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Data harmonization functions for multi-cycle CCHS analysis."""
22

3+
import re
4+
import unicodedata
5+
36
import pandas as pd
47
from typing import Optional
58

@@ -15,6 +18,95 @@ def _category_key(value):
1518
return str(value)
1619

1720

21+
def _normalize_metadata_text(value):
22+
text = unicodedata.normalize("NFKC", str(value or "")).casefold()
23+
return " ".join(re.sub(r"[^\w]+", " ", text).split())
24+
25+
26+
def assess_harmonized_compatibility(
27+
variable: str,
28+
cycles: list,
29+
crosswalk: dict,
30+
cycle_variable_info: dict,
31+
):
32+
"""Return whether a crosswalk entry is safe to pool and the reason.
33+
34+
Availability is not enough for pooling. This conservative check requires
35+
equivalent normalized descriptions and equivalent category-label sets in
36+
every selected cycle. Continuous variables are accepted when all cycles
37+
consistently have no categorical metadata.
38+
"""
39+
variable_crosswalk = crosswalk.get(variable, {})
40+
metadata = []
41+
for cycle_value in cycles:
42+
cycle = str(cycle_value)
43+
cycle_variable = variable_crosswalk.get(cycle)
44+
if not cycle_variable:
45+
return False, f"No crosswalk variable for cycle {cycle}."
46+
variable_info = cycle_variable_info.get(cycle, {}).get(cycle_variable)
47+
if not variable_info:
48+
return False, f"No data-dictionary metadata for {cycle_variable} in {cycle}."
49+
description = _normalize_metadata_text(variable_info.get("description"))
50+
if not description:
51+
return False, f"No description for {cycle_variable} in cycle {cycle}."
52+
category_labels = {
53+
_normalize_metadata_text(label)
54+
for label in variable_info.get("categories", {}).values()
55+
if _normalize_metadata_text(label)
56+
}
57+
metadata.append((cycle, cycle_variable, description, category_labels))
58+
59+
descriptions = {item[2] for item in metadata}
60+
if len(descriptions) != 1:
61+
details = "; ".join(
62+
f"{cycle} {cycle_variable}"
63+
for cycle, cycle_variable, _, _ in metadata
64+
)
65+
return False, f"Descriptions differ across mapped variables ({details})."
66+
67+
category_sets = [item[3] for item in metadata]
68+
categorical_cycles = [
69+
metadata[index][0]
70+
for index, labels in enumerate(category_sets)
71+
if labels
72+
]
73+
if categorical_cycles and len(categorical_cycles) != len(metadata):
74+
missing = [
75+
metadata[index][0]
76+
for index, labels in enumerate(category_sets)
77+
if not labels
78+
]
79+
return False, (
80+
"Categorical metadata is missing for cycle(s): "
81+
+ ", ".join(missing)
82+
+ "."
83+
)
84+
if category_sets and any(labels != category_sets[0] for labels in category_sets[1:]):
85+
return False, "Response category labels differ across cycles."
86+
87+
return True, "Descriptions and category structures match across cycles."
88+
89+
90+
def filter_poolable_variables(
91+
variables: list,
92+
cycles: list,
93+
crosswalk: dict,
94+
cycle_variable_info: dict,
95+
):
96+
"""Split common columns into poolable variables and incompatibility reasons."""
97+
poolable = []
98+
issues = {}
99+
for variable in variables:
100+
compatible, reason = assess_harmonized_compatibility(
101+
variable, cycles, crosswalk, cycle_variable_info
102+
)
103+
if compatible:
104+
poolable.append(variable)
105+
else:
106+
issues[variable] = reason
107+
return poolable, issues
108+
109+
18110
def prepare_pooled_variable(
19111
data: pd.DataFrame,
20112
variable: str,

tests/test_cycle_pooling.py

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44
import pytest
55

66
from src.analysis.bootstrap import run_cycle_pooled_analysis
7-
from src.data.harmonizer import POOLED_VALUE_COLUMN, prepare_pooled_variable
7+
from src.data.harmonizer import (
8+
POOLED_VALUE_COLUMN,
9+
assess_harmonized_compatibility,
10+
filter_poolable_variables,
11+
prepare_pooled_variable,
12+
)
813

914

1015
def test_pooling_scales_weights_to_average_annual_population():
@@ -243,3 +248,99 @@ def test_cycle_dictionary_crosswalk_detects_genuinely_missing_categories():
243248
cycle_variable_info=cycle_info,
244249
crosswalk=crosswalk,
245250
)
251+
252+
253+
def test_harmonized_compatibility_accepts_matching_meaning_and_categories():
254+
crosswalk = {
255+
"SMKDVSTY": {"2023": "SMKDVSTY", "2024": "SMKDVSTY"}
256+
}
257+
variable_info = {
258+
cycle: {
259+
"SMKDVSTY": {
260+
"description": "Smoking status (type 2) - traditional definition - (D)",
261+
"categories": {
262+
"01": "Current daily smoker",
263+
"03": "Former daily smoker (non-smoker now)",
264+
},
265+
}
266+
}
267+
for cycle in ("2023", "2024")
268+
}
269+
270+
compatible, reason = assess_harmonized_compatibility(
271+
"SMKDVSTY", ["2023", "2024"], crosswalk, variable_info
272+
)
273+
274+
assert compatible is True
275+
assert "match" in reason
276+
277+
278+
def test_harmonized_compatibility_rejects_same_name_with_different_meaning():
279+
crosswalk = {"STATUS": {"2023": "STATUS", "2024": "STATUS"}}
280+
variable_info = {
281+
"2023": {
282+
"STATUS": {"description": "Current smoking status", "categories": {}}
283+
},
284+
"2024": {
285+
"STATUS": {"description": "Former smoking status", "categories": {}}
286+
},
287+
}
288+
289+
compatible, reason = assess_harmonized_compatibility(
290+
"STATUS", ["2023", "2024"], crosswalk, variable_info
291+
)
292+
293+
assert compatible is False
294+
assert "Descriptions differ" in reason
295+
296+
297+
def test_harmonized_compatibility_rejects_changed_categories():
298+
crosswalk = {"STATUS": {"2023": "OLD_STATUS", "2024": "STATUS"}}
299+
variable_info = {
300+
"2023": {
301+
"OLD_STATUS": {
302+
"description": "Smoking status",
303+
"categories": {"1": "Daily", "2": "Occasional"},
304+
}
305+
},
306+
"2024": {
307+
"STATUS": {
308+
"description": "Smoking status",
309+
"categories": {"1": "Daily", "2": "Never"},
310+
}
311+
},
312+
}
313+
314+
compatible, reason = assess_harmonized_compatibility(
315+
"STATUS", ["2023", "2024"], crosswalk, variable_info
316+
)
317+
318+
assert compatible is False
319+
assert "category labels differ" in reason
320+
321+
322+
def test_poolable_filter_excludes_false_crosswalk_match():
323+
crosswalk = {
324+
"SAFE": {"2023": "SAFE", "2024": "SAFE"},
325+
"SPU_10": {"2023": "SPU_10", "2024": "SPU_10B"},
326+
}
327+
variable_info = {
328+
"2023": {
329+
"SAFE": {"description": "Exact measure", "categories": {}},
330+
"SPU_10": {"description": "Stopped smoking - when", "categories": {}},
331+
},
332+
"2024": {
333+
"SAFE": {"description": "Exact measure", "categories": {}},
334+
"SPU_10B": {"description": "Stopped smoking - year", "categories": {}},
335+
},
336+
}
337+
338+
poolable, issues = filter_poolable_variables(
339+
["SAFE", "SPU_10"],
340+
["2023", "2024"],
341+
crosswalk,
342+
variable_info,
343+
)
344+
345+
assert poolable == ["SAFE"]
346+
assert "SPU_10" in issues

0 commit comments

Comments
 (0)