diff --git a/changelog.d/sipp-positive-weights.fixed b/changelog.d/sipp-positive-weights.fixed new file mode 100644 index 000000000..97bf5a911 --- /dev/null +++ b/changelog.d/sipp-positive-weights.fixed @@ -0,0 +1,3 @@ +Filter non-positive SIPP donor weights before fitting source imputation models. +Interpret SIPP status flags with Census status semantics when filtering observed donor targets. +Bump policyengine-us to 1.703.1. diff --git a/policyengine_us_data/calibration/source_impute.py b/policyengine_us_data/calibration/source_impute.py index 027acf8ff..dbae82bd4 100644 --- a/policyengine_us_data/calibration/source_impute.py +++ b/policyengine_us_data/calibration/source_impute.py @@ -82,6 +82,7 @@ from policyengine_us_data.pipeline_schema import PipelineNode from policyengine_us_data.utils.source_quality import ( cap_training_sample, + filter_positive_finite_weight_rows, require_columns_present, target_observed_source_masks, ) @@ -710,6 +711,12 @@ def _impute_sipp( "household_weight", ] tip_train = sipp_df[tip_cols].dropna() + tip_train, tip_target_filters = filter_positive_finite_weight_rows( + tip_train, + weight_col="household_weight", + target_filters=tip_target_filters, + context_name="SIPP source tip donor", + ) tip_train, tip_target_filters = cap_training_sample( tip_train, max_train_samples=10_000, @@ -849,6 +856,12 @@ def _impute_sipp( target_source_columns=SIPP_ASSET_TARGET_SOURCE_COLUMNS, target_allocation_flag_columns=SIPP_ASSET_TARGET_ALLOCATION_COLUMNS, ) + asset_train, asset_target_filters = filter_positive_finite_weight_rows( + asset_train, + weight_col="household_weight", + target_filters=asset_target_filters, + context_name="SIPP source asset donor", + ) asset_train, asset_target_filters = cap_training_sample( asset_train, max_train_samples=20_000, @@ -1013,6 +1026,12 @@ def _impute_sipp( targets=vehicle_vars, target_allocation_flag_columns=SIPP_VEHICLE_TARGET_ALLOCATION_COLUMNS, ) + vehicle_train, vehicle_target_filters = filter_positive_finite_weight_rows( + vehicle_train, + weight_col="household_weight", + target_filters=vehicle_target_filters, + context_name="SIPP source vehicle donor", + ) vehicle_train, vehicle_target_filters = cap_training_sample( vehicle_train, max_train_samples=20_000, diff --git a/policyengine_us_data/datasets/sipp/sipp.py b/policyengine_us_data/datasets/sipp/sipp.py index 6fee8fc39..e34657a3b 100644 --- a/policyengine_us_data/datasets/sipp/sipp.py +++ b/policyengine_us_data/datasets/sipp/sipp.py @@ -11,6 +11,7 @@ ) from policyengine_us_data.utils.source_quality import ( cap_training_sample, + filter_positive_finite_weight_rows, filter_observed_source_rows, require_columns_present, sipp_allocation_flag_for, @@ -188,6 +189,12 @@ def train_tip_model(): ] sipp = sipp[~sipp.isna().any(axis=1)] + sipp, tip_target_filters = filter_positive_finite_weight_rows( + sipp, + weight_col="household_weight", + target_filters=tip_target_filters, + context_name="SIPP tip donor", + ) sipp, tip_target_filters = cap_training_sample( sipp, max_train_samples=10_000, @@ -232,9 +239,40 @@ def get_tip_model() -> QRF: "stock_assets": ["TVAL_STMF"], "bond_assets": ["TVAL_BOND"], } +SIPP_BANK_ACCOUNT_ASSET_ALLOCATION_COLUMNS = [ + "AJSSAVVAL", + "AJOSAVVAL", + "AOSAVVAL", + "AJSMMVAL", + "AJOMMVAL", + "AOMMVAL", + "AJSCDVAL", + "AJOCDVAL", + "AOCDVAL", + "AJSCHKVAL", + "AJOCHKVAL", + "AOCHKVAL", +] +SIPP_STOCK_ASSET_ALLOCATION_COLUMNS = [ + "AJSSTVAL", + "AJOSTVAL", + "AOSTVAL", + "AJSMFVAL", + "AJOMFVAL", + "AOMFVAL", +] +SIPP_BOND_ASSET_ALLOCATION_COLUMNS = [ + "AJSGOVSVAL", + "AJOGOVSVAL", + "AOGOVSVAL", + "AJSMCBDVAL", + "AJOMCBDVAL", + "AOMCBDVAL", +] SIPP_ASSET_TARGET_ALLOCATION_COLUMNS = { - target: [sipp_allocation_flag_for(column) for column in columns] - for target, columns in SIPP_ASSET_TARGET_SOURCE_COLUMNS.items() + "bank_account_assets": SIPP_BANK_ACCOUNT_ASSET_ALLOCATION_COLUMNS, + "stock_assets": SIPP_STOCK_ASSET_ALLOCATION_COLUMNS, + "bond_assets": SIPP_BOND_ASSET_ALLOCATION_COLUMNS, } SIPP_ASSET_ALLOCATION_COLUMNS = sorted( { @@ -326,7 +364,7 @@ def get_tip_model() -> QRF: SIPP_VEHICLE_TARGET_ALLOCATION_COLUMNS = { "household_vehicles_owned": [sipp_allocation_flag_for("TVEH_NUM")], - "household_vehicles_value": [sipp_allocation_flag_for("THVAL_VEH")], + "household_vehicles_value": ["AVEH1VAL", "AVEH2VAL", "AVEH3VAL"], } VEHICLE_COLUMNS = [ @@ -347,6 +385,9 @@ def get_tip_model() -> QRF: "THVAL_HOME", "AVEH_NUM", "AHVAL_VEH", + "AVEH1VAL", + "AVEH2VAL", + "AVEH3VAL", ] @@ -652,6 +693,12 @@ def train_asset_model(): target_source_columns=SIPP_ASSET_TARGET_SOURCE_COLUMNS, target_allocation_flag_columns=SIPP_ASSET_TARGET_ALLOCATION_COLUMNS, ) + sipp, asset_target_filters = filter_positive_finite_weight_rows( + sipp, + weight_col="household_weight", + target_filters=asset_target_filters, + context_name="SIPP asset donor", + ) sipp, asset_target_filters = cap_training_sample( sipp, max_train_samples=20_000, @@ -799,6 +846,9 @@ def build_vehicle_training_frame() -> pd.DataFrame: "household_vehicles_value": grouped["THVAL_VEH"].first().fillna(0), "AVEH_NUM": grouped["AVEH_NUM"].max().fillna(0), "AHVAL_VEH": grouped["AHVAL_VEH"].first().fillna(0), + "AVEH1VAL": grouped["AVEH1VAL"].max().fillna(0), + "AVEH2VAL": grouped["AVEH2VAL"].max().fillna(0), + "AVEH3VAL": grouped["AVEH3VAL"].max().fillna(0), "is_homeowner": (grouped["THVAL_HOME"].first().fillna(0) > 0).astype( np.float32 ), @@ -839,6 +889,12 @@ def train_vehicle_model(): targets=vehicle_vars, target_allocation_flag_columns=SIPP_VEHICLE_TARGET_ALLOCATION_COLUMNS, ) + sipp, vehicle_target_filters = filter_positive_finite_weight_rows( + sipp, + weight_col="household_weight", + target_filters=vehicle_target_filters, + context_name="SIPP vehicle donor", + ) sipp, vehicle_target_filters = cap_training_sample( sipp, max_train_samples=20_000, diff --git a/policyengine_us_data/utils/source_quality.py b/policyengine_us_data/utils/source_quality.py index a31560dd8..c3ea2ec7c 100644 --- a/policyengine_us_data/utils/source_quality.py +++ b/policyengine_us_data/utils/source_quality.py @@ -12,6 +12,18 @@ logger = logging.getLogger(__name__) +SIPP_OBSERVED_STATUS_VALUES = frozenset((0, 1, 9)) +SIPP_STATUS_FLAG_PREFIXES = ( + "AJB", + "AJS", + "AJO", + "AO", + "ASSI", + "AVAL", + "AVEH", + "AHVAL", +) + def sipp_allocation_flag_for(source_column: str) -> str: """Return the SIPP allocation flag name for a source variable.""" @@ -20,6 +32,11 @@ def sipp_allocation_flag_for(source_column: str) -> str: return f"A{source_column[1:]}" +def is_sipp_status_flag_column(column: str) -> bool: + """Return whether a column name looks like a Census SIPP status flag.""" + return column.startswith(SIPP_STATUS_FLAG_PREFIXES) + + def require_columns_present( available_columns: Container[str], required_columns: Sequence[str], @@ -47,9 +64,13 @@ def observed_source_mask( ) -> pd.Series: """Mask rows whose donor source values are observed for one target. - Source-survey allocation flags conventionally use ``0`` for not allocated - and non-zero values for allocated/imputed. Missing flag columns are ignored - so callers can use this helper across sources with different flag coverage. + Generic allocation flags use ``0`` for not allocated and non-zero values + for allocated/imputed. Census SIPP ``A*`` status flags instead encode + ``0`` as not in universe, ``1`` as reported, and ``9`` as derivable from + component flags; values ``2`` through ``8`` indicate imputation. + + Missing flag columns are ignored so callers can use this helper across + sources with different flag coverage. """ mask = pd.Series(True, index=df.index) @@ -62,7 +83,10 @@ def observed_source_mask( if column not in df: continue flag = pd.to_numeric(df[column], errors="coerce").fillna(0) - mask &= flag.eq(0) + if is_sipp_status_flag_column(column): + mask &= flag.isin(SIPP_OBSERVED_STATUS_VALUES) + else: + mask &= flag.eq(0) return mask @@ -225,3 +249,52 @@ def cap_training_sample( for target, mask in filters.items() } return sampled_df, sampled_filters + + +def filter_positive_finite_weight_rows( + df: pd.DataFrame, + *, + weight_col: str, + target_filters: Mapping[str, pd.Series] | None = None, + context_name: str = "donor training frame", +) -> tuple[pd.DataFrame, dict[str, pd.Series]]: + """Drop rows whose fit weight cannot be passed to microimpute.""" + if weight_col not in df: + raise KeyError(f"{context_name} is missing weight column {weight_col!r}") + + filters = {} + for target, mask in (target_filters or {}).items(): + aligned = mask.reindex(df.index) + if aligned.isna().any(): + raise ValueError(f"target_filters[{target!r}] contains missing values") + filters[target] = aligned.astype(bool) + + weights = pd.to_numeric(df[weight_col], errors="coerce") + valid_weight = np.isfinite(weights) & weights.gt(0) + dropped = int((~valid_weight).sum()) + if dropped: + logger.info( + "Dropped %d/%d %s rows with non-positive or non-finite %s", + dropped, + len(df), + context_name, + weight_col, + ) + + filtered_df = df.loc[valid_weight].copy().reset_index(drop=True) + filtered_filters = { + target: pd.Series( + mask.loc[valid_weight].to_numpy(dtype=bool), + index=filtered_df.index, + ) + for target, mask in filters.items() + } + + for target, mask in filtered_filters.items(): + if not mask.any(): + raise ValueError( + f"No observed donor rows with positive finite {weight_col} " + f"available for {target}" + ) + + return filtered_df, filtered_filters diff --git a/pyproject.toml b/pyproject.toml index c55886690..e6c05b530 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "policyengine-us==1.702.1", + "policyengine-us==1.703.1", # policyengine-core 3.26.1 is the current 3.26.x runtime and includes the fix for # PolicyEngine/policyengine-core#482 (user-set ETERNITY inputs lost # after _invalidate_all_caches) and is required by policyengine-us 1.682.1+. diff --git a/tests/unit/calibration/test_source_impute.py b/tests/unit/calibration/test_source_impute.py index b8c064ac4..1ccb2b7d3 100644 --- a/tests/unit/calibration/test_source_impute.py +++ b/tests/unit/calibration/test_source_impute.py @@ -410,7 +410,7 @@ def test_calibration_sipp_qrf_passes_target_filters(self, monkeypatch): for column in source_impute.SIPP_TIP_AMOUNT_COLUMNS: tip_columns[column] = [10.0, 5.0, 0.0] for column in source_impute.SIPP_TIP_AMOUNT_TO_ALLOCATION_COLUMN.values(): - tip_columns[column] = [0, 1, 0] + tip_columns[column] = [1, 2, 0] for column in source_impute.SIPP_JOB_OCCUPATION_COLUMNS: tip_columns[column] = [0, 0, 0] tip_source = pd.DataFrame(tip_columns) @@ -437,8 +437,8 @@ def test_calibration_sipp_qrf_passes_target_filters(self, monkeypatch): asset_columns[column] = [1_000.0, 2_000.0, 0.0] for column in source_impute.SIPP_ASSET_ALLOCATION_COLUMNS: asset_columns[column] = [0, 0, 0] - asset_columns["AVAL_BANK"] = [0, 1, 0] - asset_columns["AVAL_STMF"] = [0, 0, 1] + asset_columns["AJSSAVVAL"] = [0, 2, 0] + asset_columns["AJSSTVAL"] = [0, 0, 6] asset_source = pd.DataFrame(asset_columns) vehicle_train = pd.DataFrame( @@ -449,8 +449,10 @@ def test_calibration_sipp_qrf_passes_target_filters(self, monkeypatch): }, "household_vehicles_owned": [1.0, 2.0, 3.0], "household_vehicles_value": [5_000.0, 10_000.0, 15_000.0], - "AVEH_NUM": [0, 1, 0], - "AHVAL_VEH": [0, 0, 1], + "AVEH_NUM": [1, 2, 1], + "AVEH1VAL": [1, 1, 5], + "AVEH2VAL": [0, 0, 0], + "AVEH3VAL": [0, 0, 0], "household_weight": [1.0, 1.0, 1.0], } ) diff --git a/tests/unit/datasets/test_sipp_ssi_disability.py b/tests/unit/datasets/test_sipp_ssi_disability.py index ac883ad8f..3cdad3441 100644 --- a/tests/unit/datasets/test_sipp_ssi_disability.py +++ b/tests/unit/datasets/test_sipp_ssi_disability.py @@ -74,7 +74,7 @@ def test_ssi_disability_training_usecols_include_label_and_income_columns(): def test_build_ssi_disability_training_frame_excludes_allocated_label_source(): frame = _base_sipp_frame() - frame.loc[0, "ASSI_YRYN"] = 1 + frame.loc[0, "ASSI_YRYN"] = 3 frame.loc[1:, "ASSI_YRYN"] = 0 frame["ASSI_BRSN"] = 0 diff --git a/tests/unit/datasets/test_sipp_tip_columns.py b/tests/unit/datasets/test_sipp_tip_columns.py index 41ccff436..de04dee1c 100644 --- a/tests/unit/datasets/test_sipp_tip_columns.py +++ b/tests/unit/datasets/test_sipp_tip_columns.py @@ -5,10 +5,14 @@ to explicit `TJB*_TXAMT` dollar-amount columns only. """ +import numpy as np import pandas as pd import pytest -from policyengine_us_data.datasets.sipp.sipp import SIPP_TIP_AMOUNT_COLUMNS +from policyengine_us_data.datasets.sipp.sipp import ( + SIPP_JOB_OCCUPATION_COLUMNS, + SIPP_TIP_AMOUNT_COLUMNS, +) import policyengine_us_data.datasets.sipp.sipp as sipp_module @@ -66,3 +70,91 @@ def test_train_tip_model_requires_allocation_flags_for_present_tip_columns( with pytest.raises(KeyError, match="AJB1_TXAMT"): sipp_module.train_tip_model() + + +def test_train_tip_model_drops_non_positive_weights(monkeypatch): + monkeypatch.setattr(sipp_module, "hf_hub_download", lambda *args, **kwargs: None) + + data = { + "SSUID": [1, 2, 3, 4], + "MONTHCODE": [12, 12, 12, 12], + "TAGE": [30, 31, 32, 33], + "WPFINWGT": [100.0, 0.0, -5.0, 200.0], + "TPTOTINC": [1_000.0, 2_000.0, 3_000.0, 4_000.0], + "TJB1_TXAMT": [10.0, 20.0, 30.0, 40.0], + "AJB1_TXAMT": [0, 0, 0, 0], + } + for column in SIPP_JOB_OCCUPATION_COLUMNS: + data[column] = [0, 0, 0, 0] + monkeypatch.setattr( + sipp_module.pd, + "read_csv", + lambda *args, **kwargs: pd.DataFrame(data), + ) + + captured = {} + + class FakeQRF: + def fit( + self, + *, + X_train, + predictors, + imputed_variables, + target_filters, + weight_col, + ): + captured["weights"] = X_train[weight_col].to_numpy() + captured["target_filter"] = target_filters["tip_income"].to_numpy() + return self + + monkeypatch.setattr(sipp_module, "QRF", FakeQRF) + + sipp_module.train_tip_model() + + np.testing.assert_array_equal(captured["weights"], [100.0, 200.0]) + np.testing.assert_array_equal(captured["target_filter"], [True, True]) + + +def test_train_tip_model_keeps_reported_sipp_status_flags(monkeypatch): + monkeypatch.setattr(sipp_module, "hf_hub_download", lambda *args, **kwargs: None) + + data = { + "SSUID": [1, 2, 3, 4], + "MONTHCODE": [12, 12, 12, 12], + "TAGE": [30, 31, 32, 33], + "WPFINWGT": [100.0, 100.0, 100.0, 100.0], + "TPTOTINC": [1_000.0, 2_000.0, 3_000.0, 4_000.0], + "TJB1_TXAMT": [10.0, 20.0, 30.0, 40.0], + "AJB1_TXAMT": [1, 2, 6, 9], + } + for column in SIPP_JOB_OCCUPATION_COLUMNS: + data[column] = [0, 0, 0, 0] + monkeypatch.setattr( + sipp_module.pd, + "read_csv", + lambda *args, **kwargs: pd.DataFrame(data), + ) + + captured = {} + + class FakeQRF: + def fit( + self, + *, + X_train, + predictors, + imputed_variables, + target_filters, + weight_col, + ): + captured["tip_income"] = X_train["tip_income"].to_numpy() + captured["target_filter"] = target_filters["tip_income"].to_numpy() + return self + + monkeypatch.setattr(sipp_module, "QRF", FakeQRF) + + sipp_module.train_tip_model() + + np.testing.assert_array_equal(captured["tip_income"], [120.0, 480.0]) + np.testing.assert_array_equal(captured["target_filter"], [True, True]) diff --git a/tests/unit/test_source_quality.py b/tests/unit/test_source_quality.py index 505d8d91a..3912a37c5 100644 --- a/tests/unit/test_source_quality.py +++ b/tests/unit/test_source_quality.py @@ -3,6 +3,8 @@ from policyengine_us_data.utils.source_quality import ( cap_training_sample, + filter_positive_finite_weight_rows, + is_sipp_status_flag_column, observed_source_mask, require_columns_present, sipp_allocation_flag_for, @@ -40,42 +42,63 @@ def test_require_columns_present_raises_for_missing_columns(): assert "Regenerate the donor artifact" in message -def test_observed_source_mask_excludes_nonzero_allocation_flags(): +def test_observed_source_mask_excludes_nonzero_binary_allocation_flags(): df = pd.DataFrame( { "TVAL_BANK": [100.0, 200.0, 300.0], - "AVAL_BANK": [0, 1, 2], + "asset_is_allocated": [0, 1, 2], } ) result = observed_source_mask( df, source_columns=["TVAL_BANK"], - allocation_flag_columns=["AVAL_BANK"], + allocation_flag_columns=["asset_is_allocated"], ) np.testing.assert_array_equal(result.values, [True, False, False]) +def test_observed_source_mask_uses_sipp_status_flag_semantics(): + df = pd.DataFrame( + { + "TJB1_TXAMT": [np.nan, 10.0, 20.0, 30.0, 40.0], + "AJB1_TXAMT": [0, 1, 2, 9, 6], + } + ) + + result = observed_source_mask( + df, + source_columns=["TJB1_TXAMT"], + allocation_flag_columns=["AJB1_TXAMT"], + require_nonmissing_source=False, + ) + + assert is_sipp_status_flag_column("AJB1_TXAMT") + assert is_sipp_status_flag_column("ASSI_YRYN") + assert not is_sipp_status_flag_column("ACS_ALLOCATED") + np.testing.assert_array_equal(result.values, [True, True, False, True, False]) + + def test_observed_source_mask_is_target_specific(): df = pd.DataFrame( { "tip_income": [10.0, 20.0], "bank_account_assets": [100.0, 200.0], - "AJB1_TXAMT": [0, 0], - "AVAL_BANK": [1, 0], + "tip_is_allocated": [0, 0], + "asset_is_allocated": [1, 0], } ) tip_mask = observed_source_mask( df, source_columns=["tip_income"], - allocation_flag_columns=["AJB1_TXAMT"], + allocation_flag_columns=["tip_is_allocated"], ) bank_mask = observed_source_mask( df, source_columns=["bank_account_assets"], - allocation_flag_columns=["AVAL_BANK"], + allocation_flag_columns=["asset_is_allocated"], ) np.testing.assert_array_equal(tip_mask.values, [True, True]) @@ -86,7 +109,7 @@ def test_observed_source_mask_allows_missing_tip_components_when_requested(): df = pd.DataFrame( { "TJB1_TXAMT": [np.nan, 5.0], - "AJB1_TXAMT": [0, 0], + "AJB1_TXAMT": [0, 1], } ) @@ -105,8 +128,8 @@ def test_target_observed_source_masks_are_target_specific(): { "tip_income": [10.0, 20.0], "bank_account_assets": [100.0, 200.0], - "AJB1_TXAMT": [0, 0], - "AVAL_BANK": [1, 0], + "tip_is_allocated": [0, 0], + "asset_is_allocated": [1, 0], } ) @@ -114,8 +137,8 @@ def test_target_observed_source_masks_are_target_specific(): df, targets=["tip_income", "bank_account_assets"], target_allocation_flag_columns={ - "tip_income": ["AJB1_TXAMT"], - "bank_account_assets": ["AVAL_BANK"], + "tip_income": ["tip_is_allocated"], + "bank_account_assets": ["asset_is_allocated"], }, ) @@ -201,3 +224,54 @@ def test_cap_training_sample_rejects_misaligned_filters(): raise AssertionError("Expected misaligned target filters to fail") assert "target_filters['value']" in message + + +def test_filter_positive_finite_weight_rows_reindexes_target_filters(): + df = pd.DataFrame( + { + "value": [10, 20, 30, 40, 50], + "household_weight": [1.0, 0.0, np.nan, np.inf, 5.0], + }, + index=[10, 11, 12, 13, 14], + ) + filters = { + "value": pd.Series( + [True, True, False, True, True], + index=df.index, + ) + } + + filtered, filtered_filters = filter_positive_finite_weight_rows( + df, + weight_col="household_weight", + target_filters=filters, + context_name="unit-test donor", + ) + + assert filtered["value"].tolist() == [10, 50] + assert filtered.index.tolist() == [0, 1] + np.testing.assert_array_equal(filtered_filters["value"].values, [True, True]) + assert filtered_filters["value"].index.tolist() == [0, 1] + + +def test_filter_positive_finite_weight_rows_requires_observed_target_rows(): + df = pd.DataFrame( + { + "value": [10, 20], + "household_weight": [0.0, 1.0], + } + ) + filters = {"value": pd.Series([True, False], index=df.index)} + + try: + filter_positive_finite_weight_rows( + df, + weight_col="household_weight", + target_filters=filters, + ) + except ValueError as error: + message = str(error) + else: + raise AssertionError("Expected all invalid observed weights to fail") + + assert "No observed donor rows with positive finite household_weight" in message diff --git a/uv.lock b/uv.lock index 593e371fd..f96ba5ab0 100644 --- a/uv.lock +++ b/uv.lock @@ -2122,7 +2122,7 @@ wheels = [ [[package]] name = "policyengine-us" -version = "1.702.1" +version = "1.703.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "microdf-python" }, @@ -2132,9 +2132,9 @@ dependencies = [ { name = "tables" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4d/3e/6000ddb6cd51bb5d832089cf2904b88773e431e358fef4f4bd736d5aea0e/policyengine_us-1.702.1.tar.gz", hash = "sha256:b3782233a8e3d6c5eca48f329cad87e46319c170eacb64836f1966e58e5f95b6", size = 9884003, upload-time = "2026-05-21T16:42:13.543Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/d7/6268c87ecb05e3aa1edaee9dc79467da8c96c69dc5b6139754bbf9e1970d/policyengine_us-1.703.1.tar.gz", hash = "sha256:951cf922550849890a73442282cc1e013852b270c3b3b4e24aca5ae29e6e811d", size = 9886740, upload-time = "2026-05-21T22:17:47.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/d7/95bbe3549a5f932ff91a53f84a78a70497b2e11e395dfd1fdd1d76ba9a71/policyengine_us-1.702.1-py3-none-any.whl", hash = "sha256:f6029ae7319219f1e36c805f778dd8594742e89867b0c5fc07459b6ee18b487e", size = 10673068, upload-time = "2026-05-21T16:42:09.985Z" }, + { url = "https://files.pythonhosted.org/packages/8f/91/dc40a435fb0af3cdf62fa476b87674a2fb4cfd221137f2c5a98ce194d96a/policyengine_us-1.703.1-py3-none-any.whl", hash = "sha256:39445e07e7616d5c4da006a0836cf8c2b326f6f6dec1c8b633bb835cf8682f35", size = 10680928, upload-time = "2026-05-21T22:17:43.642Z" }, ] [[package]] @@ -2204,7 +2204,7 @@ requires-dist = [ { name = "pandas", specifier = ">=2.3.1" }, { name = "pip-system-certs", specifier = ">=3.0" }, { name = "policyengine-core", specifier = ">=3.26.1,<3.27" }, - { name = "policyengine-us", specifier = "==1.702.1" }, + { name = "policyengine-us", specifier = "==1.703.1" }, { name = "requests", specifier = ">=2.25.0" }, { name = "samplics", marker = "extra == 'calibration'" }, { name = "scipy", specifier = ">=1.15.3" },