From 1eb723120190ecd67d2c664500e016c938848512 Mon Sep 17 00:00:00 2001 From: DGoettlich Date: Thu, 23 Apr 2026 13:43:44 +0200 Subject: [PATCH 1/8] [scpc] utils for SCPCResult summary methods --- src/scpc/utils/data.py | 10 +++++++-- src/scpc/utils/results.py | 33 +++++++++++++++++++++++++++ tests/test_get_coef_names.py | 36 ++++++++++++++++++++++++++++++ tests/test_resolve_parm_indices.py | 17 ++++++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 src/scpc/utils/results.py create mode 100644 tests/test_get_coef_names.py create mode 100644 tests/test_resolve_parm_indices.py diff --git a/src/scpc/utils/data.py b/src/scpc/utils/data.py index 0db40ba..1f59f12 100644 --- a/src/scpc/utils/data.py +++ b/src/scpc/utils/data.py @@ -1,7 +1,5 @@ from __future__ import annotations - from collections.abc import Sequence - import numpy as np import pandas as pd @@ -31,6 +29,14 @@ def get_pyfixest_coef_names(model: ModelLike) -> list[str]: return [str(name) for name in getattr(model, "_coefnames", [])] +def get_coef_names(model: ModelLike) -> list[str]: + """Return coefficient names in coefficient order.""" + if is_pyfixest_model(model): + return get_pyfixest_coef_names(model) + + return [str(name) for name in model.model.exog_names] + + def get_pyfixest_data(model: ModelLike) -> DataFrameLike: """Return the stored pyfixest estimation sample. diff --git a/src/scpc/utils/results.py b/src/scpc/utils/results.py new file mode 100644 index 0000000..d474bce --- /dev/null +++ b/src/scpc/utils/results.py @@ -0,0 +1,33 @@ +from __future__ import annotations +from collections.abc import Sequence + + +def resolve_parm_indices( + coef_names: Sequence[str], + parm: str | int | Sequence[str] | Sequence[int] | None, +) -> list[int]: + """Resolve `confint(parm=...)` to zero-based coefficient indices. + TODO: overcomplicates things but makes it more similar to R behavior + when it comes to the behavior in the SCPCResult methods + """ + # return all if no parameter selected + if parm is None: + return list(range(len(coef_names))) + + # single coef by name + if isinstance(parm, str): + return [coef_names.index(parm)] + + # index of coef + if isinstance(parm, int): + return [parm] + + values = list(parm) + if not values: + return [] + + # check if list of names or indices + if isinstance(values[0], str): + return [coef_names.index(str(value)) for value in values] + + return [int(value) for value in values] diff --git a/tests/test_get_coef_names.py b/tests/test_get_coef_names.py new file mode 100644 index 0000000..d0b02bc --- /dev/null +++ b/tests/test_get_coef_names.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import pandas as pd +import pyfixest as pf +import statsmodels.formula.api as smf + +from scpc.utils.data import get_coef_names +from tests.utils import make_basic_iv_data, make_one_way_fe_iv_data + + +def test_get_coef_names_returns_statsmodels_names() -> None: + data = pd.DataFrame({"y": [1.0, 2.0, 3.0], "x": [0.0, 1.0, 2.0]}) + model = smf.ols("y ~ x", data=data).fit() + + assert get_coef_names(model) == ["Intercept", "x"] + + +def test_get_coef_names_returns_pyfixest_names() -> None: + data = pd.DataFrame({"y": [1.0, 2.0, 3.0], "x": [0.0, 1.0, 2.0]}) + model = pf.feols("y ~ x", data=data) + + assert get_coef_names(model) == ["Intercept", "x"] + + +def test_get_coef_names_returns_pyfixest_iv_names() -> None: + data = make_basic_iv_data(2001) + model = pf.feols("y ~ w | x ~ z", data=data) + + assert get_coef_names(model) == ["Intercept", "w", "x"] + + +def test_get_coef_names_returns_pyfixest_fe_iv_names() -> None: + data = make_one_way_fe_iv_data(2002) + model = pf.feols("y ~ w | fe | x ~ z", data=data) + + assert get_coef_names(model) == ["w", "x"] diff --git a/tests/test_resolve_parm_indices.py b/tests/test_resolve_parm_indices.py new file mode 100644 index 0000000..496f53c --- /dev/null +++ b/tests/test_resolve_parm_indices.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from scpc.utils.results import resolve_parm_indices + + +def test_resolve_parm_indices_selects_all_for_none() -> None: + assert resolve_parm_indices(["Intercept", "x", "w"], None) == [0, 1, 2] + + +def test_resolve_parm_indices_selects_by_name() -> None: + assert resolve_parm_indices(["Intercept", "x", "w"], "w") == [2] + assert resolve_parm_indices(["Intercept", "x", "w"], ["w", "x"]) == [2, 1] + + +def test_resolve_parm_indices_selects_by_index() -> None: + assert resolve_parm_indices(["Intercept", "x", "w"], 2) == [2] + assert resolve_parm_indices(["Intercept", "x", "w"], [2, 1]) == [2, 1] From 5f624b99fe1be676104b50f912c5bef9d119080f Mon Sep 17 00:00:00 2001 From: DGoettlich Date: Thu, 23 Apr 2026 14:00:42 +0200 Subject: [PATCH 2/8] [scpc] summary methods for SCPCResult --- src/scpc/core.py | 8 +- src/scpc/types.py | 95 ++++++++++++++++-- src/scpc/utils/data.py | 23 +++-- src/scpc/utils/matrix.py | 32 ++++-- .../test_get_conditional_projection_setup.py | 13 ++- tests/test_get_fixest_iv_design.py | 10 +- tests/test_get_fixest_score_matrix.py | 3 +- tests/test_get_obs_index.py | 6 +- tests/test_is_fixest_iv_second_stage.py | 1 - tests/test_scpc.py | 24 +++++ tests/test_scpc_pyfixest_iv.py | 2 + tests/test_scpc_result_methods.py | 97 +++++++++++++++++++ tests/utils.py | 19 ++-- 13 files changed, 289 insertions(+), 44 deletions(-) create mode 100644 tests/test_scpc_result_methods.py diff --git a/src/scpc/core.py b/src/scpc/core.py index a2d297b..ee6695c 100644 --- a/src/scpc/core.py +++ b/src/scpc/core.py @@ -8,6 +8,7 @@ from .types import DataFrameLike, ModelLike, SCPCResult from .utils.data import ( + get_coef_names, get_conditional_projection_setup, get_fixest_bread_inv, get_fixest_score_matrix, @@ -83,8 +84,7 @@ def scpc( if is_pyfixest_multi(model): raise ValueError( - "`scpc()` only accepts a single fitted pyfixest model, not " - "FixestMulti." + "`scpc()` only accepts a single fitted pyfixest model, not FixestMulti." ) model_mat = get_scpc_model_matrix(model) @@ -188,7 +188,9 @@ def scpc( q = wfin.shape[1] - 1 large_n_random_state = spc.random_state + raw_coef_names = get_coef_names(model) k_use = p if ncoef is None else min(ncoef, p) + coef_names = raw_coef_names[:k_use] out = np.full((k_use, 6), np.nan) levs = np.array([0.32, 0.10, 0.05, 0.01], dtype=float) cvs_mat = np.full((k_use, 4), np.nan) if cvs else None @@ -325,7 +327,7 @@ def scpc( c0=spc.c0, cv=cvfin, q=q, + coef_names=coef_names, method=spc.method, large_n_seed=large_n_seed, - call=None, # TODO: add string representation or remove from result ) diff --git a/src/scpc/types.py b/src/scpc/types.py index d1c2130..0dd6542 100644 --- a/src/scpc/types.py +++ b/src/scpc/types.py @@ -4,11 +4,20 @@ from dataclasses import dataclass from typing import Any, TypeAlias +import numpy as np +import pandas as pd + +from .utils.results import resolve_parm_indices + ArrayLike: TypeAlias = Any MatrixLike: TypeAlias = Any ModelLike: TypeAlias = Any DataFrameLike: TypeAlias = Any +SCPC_STATS_COLUMNS = ["Coef", "Std_Err", "t", "P>|t|", "2.5 %", "97.5 %"] +SCPC_CV_COLUMNS = ["32%", "10%", "5%", "1%"] +SCPC_CV_LEVELS = {0.68: 0, 0.90: 1, 0.95: 2, 0.99: 3} + @dataclass(slots=True) class CoordinateData: @@ -82,12 +91,12 @@ class SCPCResult: """Default 5 percent critical value used for intervals.""" q: int """Number of spatial components kept in the final projection.""" - method: str = "exact" + coef_names: list[str] + """Coefficient names aligned to rows of `scpcstats` and `scpccvs`.""" + method: str = "exact" # this is the actually used setting, so "auto" is missing """Spatial method actually used: `exact` or `approx`.""" large_n_seed: int = 1 """Seed used by the large-n approximation branch.""" - call: str | None = None - """Text version of the original call, when available.""" def __repr__(self) -> str: """Return a developer-oriented representation of the result. @@ -99,7 +108,10 @@ def __repr__(self) -> str: Returns: A representation string. """ - pass + return ( + f"SCPCResult(ncoef={len(self.coef_names)}, q={self.q}, " + f"avc={self.avc!r}, method={self.method!r})" + ) def __str__(self) -> str: """Return a user-facing summary string. @@ -111,7 +123,24 @@ def __str__(self) -> str: Returns: A formatted summary string. """ - pass + stats = pd.DataFrame( + np.asarray(self.scpcstats, dtype=float), + index=self.coef_names, + columns=SCPC_STATS_COLUMNS, + ) + lines = [ + f"SCPC Inference (ncoef = {len(self.coef_names)}, q = {self.q})", + "", + stats.iloc[:, :4].to_string(), + ] + if self.scpccvs is not None: + cvs = pd.DataFrame( + np.asarray(self.scpccvs, dtype=float), + index=self.coef_names, + columns=SCPC_CV_COLUMNS, + ) + lines.extend(["", "Two-sided critical values:", cvs.to_string()]) + return "\n".join(lines) def summary(self) -> str: """Return an extended formatted summary. @@ -123,7 +152,30 @@ def summary(self) -> str: Returns: A formatted summary string. """ - pass + stats = pd.DataFrame( + np.asarray(self.scpcstats, dtype=float), + index=self.coef_names, + columns=SCPC_STATS_COLUMNS, + ) + lines = [ + ( + f"SCPC Inference (ncoef = {len(self.coef_names)}, " + f"q = {self.q}, avc = {self.avc})" + ), + "", + stats.iloc[:, :4].to_string(), + "", + "95% Confidence Intervals:", + self.confint().to_string(), + ] + if self.scpccvs is not None: + cvs = pd.DataFrame( + np.asarray(self.scpccvs, dtype=float), + index=self.coef_names, + columns=SCPC_CV_COLUMNS, + ) + lines.extend(["", "Two-sided critical values:", cvs.to_string()]) + return "\n".join(lines) def coef(self) -> Any: """Return the coefficient estimates. @@ -135,11 +187,12 @@ def coef(self) -> Any: Returns: The coefficient estimates. """ - pass + stats = np.asarray(self.scpcstats, dtype=float) + return pd.Series(stats[:, 0], index=self.coef_names, name="Coef") def confint( self, - parm: Sequence[str] | Sequence[int] | None = None, + parm: str | int | Sequence[str] | Sequence[int] | None = None, level: float = 0.95, ) -> Any: """Return confidence intervals for selected coefficients. @@ -159,4 +212,28 @@ def confint( ValueError: Raised later for unknown coefficients or unsupported confidence levels. """ - pass + idx = resolve_parm_indices(self.coef_names, parm) + stats = np.asarray(self.scpcstats, dtype=float) + names = [self.coef_names[i] for i in idx] + + if level == 0.95: + values = stats[idx, 4:6] + else: + if self.scpccvs is None: + raise ValueError(f"Confidence level {level} is not available.") + level_idx = SCPC_CV_LEVELS[level] + cvs = np.asarray(self.scpccvs, dtype=float) + cv_vals = cvs[idx, level_idx] + coef_vals = stats[idx, 0] + se_vals = stats[idx, 1] + values = np.column_stack( + (coef_vals - cv_vals * se_vals, coef_vals + cv_vals * se_vals) + ) + + lower = 100 * (1 - level) / 2 + upper = 100 * (1 + level) / 2 + return pd.DataFrame( + values, + index=names, + columns=[f"{lower:g} %", f"{upper:g} %"], + ) diff --git a/src/scpc/utils/data.py b/src/scpc/utils/data.py index 1f59f12..36eba6e 100644 --- a/src/scpc/utils/data.py +++ b/src/scpc/utils/data.py @@ -65,7 +65,9 @@ def get_pyfixest_named_columns( missing = [name for name in names if name not in data.columns] if missing: - raise ValueError(f"{context} columns are missing from the stored pyfixest data.") + raise ValueError( + f"{context} columns are missing from the stored pyfixest data." + ) values = np.asarray(data.loc[:, names], dtype=float) if values.ndim != 2 or values.shape != (n, len(names)): @@ -98,7 +100,9 @@ def demean(values: MatrixLike, *, context: str) -> np.ndarray: if values.ndim != 2: raise ValueError(f"{context} must be a vector or matrix.") if values.shape[0] != len(fe): - raise ValueError(f"{context} does not line up with the stored fixed effects.") + raise ValueError( + f"{context} does not line up with the stored fixed effects." + ) if values.shape[1] == 0: return values[:, 0] if vec_input else values @@ -218,8 +222,7 @@ def get_scpc_model_matrix(model: ModelLike) -> MatrixLike: if is_pyfixest_model(model): if is_pyfixest_multi(model): raise ValueError( - "`scpc()` only accepts a single fitted pyfixest model, not " - "FixestMulti." + "`scpc()` only accepts a single fitted pyfixest model, not FixestMulti." ) if is_fixest_iv_second_stage(model): @@ -321,7 +324,9 @@ def has_fixest_fe(model: ModelLike) -> bool: return fixef_vars is not None and len(fixef_vars) > 0 -def get_fixest_iv_design(model: ModelLike) -> dict[str, MatrixLike | list[str] | None | bool]: +def get_fixest_iv_design( + model: ModelLike, +) -> dict[str, MatrixLike | list[str] | None | bool]: """Extract the stored IV design objects from a pyfixest fit. This mirrors `scpcR:::.get_fixest_iv_design()`. In the R code that helper @@ -380,9 +385,7 @@ def get_fixest_iv_design(model: ModelLike) -> dict[str, MatrixLike | list[str] | has_intercept = "Intercept" in coef_names exo_no_intercept = [name for name in exo_names if name != "Intercept"] intercept = ( - np.ones((n, 1), dtype=float) - if has_intercept - else np.empty((n, 0), dtype=float) + np.ones((n, 1), dtype=float) if has_intercept else np.empty((n, 0), dtype=float) ) exo = get_pyfixest_named_columns( data, @@ -478,7 +481,9 @@ def get_conditional_projection_setup( if bool(design["has_fixef"]): demean = make_pyfixest_demeaner(model) model_mat_iv = np.asarray( - demean(model_mat_iv, context="pyfixest IV second-stage model matrix"), + demean( + model_mat_iv, context="pyfixest IV second-stage model matrix" + ), dtype=float, ) else: diff --git a/src/scpc/utils/matrix.py b/src/scpc/utils/matrix.py index 9ad5d52..7656a4c 100644 --- a/src/scpc/utils/matrix.py +++ b/src/scpc/utils/matrix.py @@ -152,7 +152,9 @@ def make_iv_residualizer( X, _ = coerce_numeric_matrix(X, "IV residualizer X") Z, _ = coerce_numeric_matrix(Z, "IV residualizer Z") if X.shape[0] != Z.shape[0]: - raise ValueError("IV residualizer requires X and Z to have the same number of rows.") + raise ValueError( + "IV residualizer requires X and Z to have the same number of rows." + ) if demean is not None: # scpcr demeans the raw iv design inside the residualizer when fixed @@ -169,7 +171,9 @@ def make_iv_residualizer( def residualize(Y: MatrixLike) -> np.ndarray: Y, vec_input = coerce_numeric_matrix(Y, "IV residualizer Y") if Y.shape[0] != X.shape[0]: - raise ValueError("IV residualizer received Y with an incompatible row count.") + raise ValueError( + "IV residualizer received Y with an incompatible row count." + ) if demean is not None: Y = np.asarray(demean(Y, context="IV residualizer Y"), dtype=float) @@ -178,7 +182,9 @@ def residualize(Y: MatrixLike) -> np.ndarray: pzy = qz @ (qz.T @ Y) coef = np.linalg.solve(A, X.T @ pzy) if not np.isfinite(coef).all(): - raise ValueError("IV residualizer produced non-finite auxiliary coefficients.") + raise ValueError( + "IV residualizer produced non-finite auxiliary coefficients." + ) return restore_residualizer_shape(Y - X @ coef, vec_input) @@ -211,11 +217,17 @@ def orthogonalize_w_iv( xjs = np.asarray(xjs, dtype=float) if len(xj) != wfin.shape[0]: - raise ValueError("Conditional IV projection received xj with an incompatible length.") + raise ValueError( + "Conditional IV projection received xj with an incompatible length." + ) if len(xjs) != wfin.shape[0]: - raise ValueError("Conditional IV projection received xjs with an incompatible length.") + raise ValueError( + "Conditional IV projection received xjs with an incompatible length." + ) if not np.isfinite(xj).all() or not np.isfinite(xjs).all(): - raise ValueError("Conditional IV projection received non-finite xj or xjs values.") + raise ValueError( + "Conditional IV projection received non-finite xj or xjs values." + ) wx = wfin.copy() wx[:, 0] = wfin[:, 0] * xj * xjs @@ -265,7 +277,9 @@ def orthogonalize_w_cluster_iv( _, cl_idx = np.unique(cl_vec, return_inverse=True) if len(xj_indiv) != len(cl_vec): - raise ValueError("Clustered IV projection received xj_indiv with an incompatible length.") + raise ValueError( + "Clustered IV projection received xj_indiv with an incompatible length." + ) if not np.isfinite(xj_indiv).all(): raise ValueError("Clustered IV projection received non-finite xj_indiv values.") @@ -292,7 +306,9 @@ def orthogonalize_w_cluster_iv( ) if not np.isfinite(wx).all(): - raise ValueError("Clustered conditional IV projection produced non-finite W values.") + raise ValueError( + "Clustered conditional IV projection produced non-finite W values." + ) return wx diff --git a/tests/test_get_conditional_projection_setup.py b/tests/test_get_conditional_projection_setup.py index 4856950..87c15e8 100644 --- a/tests/test_get_conditional_projection_setup.py +++ b/tests/test_get_conditional_projection_setup.py @@ -91,10 +91,15 @@ def test_get_conditional_projection_setup_marks_pyfixest_iv_models() -> None: model_mat = np.asarray(model._X, dtype=float) design = get_fixest_iv_design(model) - setup = get_conditional_projection_setup(model, model_mat, n=model_mat.shape[0], uncond=False) + setup = get_conditional_projection_setup( + model, model_mat, n=model_mat.shape[0], uncond=False + ) npt.assert_allclose( - setup.model_mat, np.asarray(design["model_mat"], dtype=float), atol=1e-12, rtol=0.0 + setup.model_mat, + np.asarray(design["model_mat"], dtype=float), + atol=1e-12, + rtol=0.0, ) assert setup.include_intercept is True assert setup.fixef_id is None @@ -188,7 +193,9 @@ def test_get_conditional_projection_setup_uses_demeaned_pyfixest_fe_matrix() -> model = pf.feols("y ~ x | fe", data=data) model_mat = np.asarray(model._X, dtype=float) - setup = get_conditional_projection_setup(model, model_mat, n=model_mat.shape[0], uncond=False) + setup = get_conditional_projection_setup( + model, model_mat, n=model_mat.shape[0], uncond=False + ) npt.assert_allclose(setup.model_mat, model_mat, atol=1e-12, rtol=0.0) assert setup.include_intercept is False diff --git a/tests/test_get_fixest_iv_design.py b/tests/test_get_fixest_iv_design.py index 18a6764..e76e1eb 100644 --- a/tests/test_get_fixest_iv_design.py +++ b/tests/test_get_fixest_iv_design.py @@ -153,14 +153,20 @@ def test_python_r_parity_get_fixest_iv_design() -> None: ) py_value = get_fixest_iv_design(model) py_coef_names = [str(name) for name in model._coefnames] - r_x = reorder_r_columns_to_py(py_coef_names, [str(name) for name in r_value["x_names"]], np.array(r_value["X"])) + r_x = reorder_r_columns_to_py( + py_coef_names, + [str(name) for name in r_value["x_names"]], + np.array(r_value["X"]), + ) r_model = reorder_r_columns_to_py( py_coef_names, [str(name) for name in r_value["model_names"]], np.array(r_value["model_mat"]), ) z_order = ["w", "z"] - r_z = reorder_r_columns_to_py(z_order, [str(name) for name in r_value["z_names"]], np.array(r_value["Z"])) + r_z = reorder_r_columns_to_py( + z_order, [str(name) for name in r_value["z_names"]], np.array(r_value["Z"]) + ) npt.assert_allclose(py_value["X"], r_x, atol=ATOL, rtol=RTOL) npt.assert_allclose(py_value["Z"], r_z, atol=ATOL, rtol=RTOL) diff --git a/tests/test_get_fixest_score_matrix.py b/tests/test_get_fixest_score_matrix.py index 1d7e0ff..0a8794b 100644 --- a/tests/test_get_fixest_score_matrix.py +++ b/tests/test_get_fixest_score_matrix.py @@ -23,7 +23,8 @@ def test_get_fixest_score_matrix_maps_iv_scores_into_coefficient_space() -> None npt.assert_allclose( np.asarray(model._scores, dtype=float), - np.asarray(model._Z, dtype=float) * np.asarray(model._u_hat, dtype=float)[:, None], + np.asarray(model._Z, dtype=float) + * np.asarray(model._u_hat, dtype=float)[:, None], atol=1e-12, rtol=0.0, ) diff --git a/tests/test_get_obs_index.py b/tests/test_get_obs_index.py index 9fd7257..216a046 100644 --- a/tests/test_get_obs_index.py +++ b/tests/test_get_obs_index.py @@ -136,7 +136,11 @@ def test_python_r_parity_get_obs_index() -> None: @pytest.mark.skipif(R is None, reason="Rscript not installed") def test_python_r_parity_get_obs_index_for_pyfixest() -> None: payload = { - "data": {"y": [1.0, 2.0, 3.0, 4.0], "x": [0.0, 1.0, None, 3.0], "z": [1.0, 0.0, 2.0, 1.0]}, + "data": { + "y": [1.0, 2.0, 3.0, 4.0], + "x": [0.0, 1.0, None, 3.0], + "z": [1.0, 0.0, 2.0, 1.0], + }, } data = pd.DataFrame(payload["data"], index=["a", "b", "c", "d"]) model = pf.feols("y ~ 1 | x ~ z", data=data) diff --git a/tests/test_is_fixest_iv_second_stage.py b/tests/test_is_fixest_iv_second_stage.py index 841b997..82409cc 100644 --- a/tests/test_is_fixest_iv_second_stage.py +++ b/tests/test_is_fixest_iv_second_stage.py @@ -1,6 +1,5 @@ from __future__ import annotations -import pandas as pd import pyfixest as pf import pytest diff --git a/tests/test_scpc.py b/tests/test_scpc.py index 264e287..6259cbe 100644 --- a/tests/test_scpc.py +++ b/tests/test_scpc.py @@ -43,6 +43,30 @@ def test_scpc_returns_a_result_with_the_expected_structure() -> None: assert result.w.shape[0] == 5 assert result.method == "exact" assert result.large_n_seed == 7 + assert result.coef_names == ["Intercept", "x"] + + +def test_scpc_stores_only_reported_coef_names_when_ncoef_is_set() -> None: + data = pd.DataFrame( + { + "y": [1.0, 1.8, 2.9, 3.7, 5.1], + "x": [0.0, 1.0, 2.0, 3.0, 4.0], + "coord_x": [0.0, 1.0, 0.5, 1.5, 2.0], + "coord_y": [0.0, 0.0, 1.0, 1.0, 1.5], + } + ) + model = smf.ols("y ~ x", data=data).fit() + + result = scpc( + model, + data, + coords_euclidean=("coord_x", "coord_y"), + ncoef=1, + uncond=True, + ) + + assert result.scpcstats.shape == (1, 6) + assert result.coef_names == ["Intercept"] def test_scpc_auto_uses_the_exact_branch_for_small_problems() -> None: diff --git a/tests/test_scpc_pyfixest_iv.py b/tests/test_scpc_pyfixest_iv.py index 22e6abe..bafc51a 100644 --- a/tests/test_scpc_pyfixest_iv.py +++ b/tests/test_scpc_pyfixest_iv.py @@ -218,6 +218,7 @@ def test_scpc_matches_r_for_one_way_fe_iv_approx() -> None: assert result.method == "approx" assert result.large_n_seed == 17 + def test_scpc_matches_r_for_clustered_iv_approx() -> None: data = make_clustered_iv_data(2003) fit = pf.feols("y ~ w | x ~ z", data=data) @@ -240,6 +241,7 @@ def test_scpc_matches_r_for_clustered_iv_approx() -> None: assert result.method == "approx" assert result.large_n_seed == 17 + def test_scpc_matches_r_for_clustered_fe_iv_approx() -> None: data = make_clustered_fe_iv_data(2004) fit = pf.feols("y ~ w | fe | x ~ z", data=data) diff --git a/tests/test_scpc_result_methods.py b/tests/test_scpc_result_methods.py new file mode 100644 index 0000000..9b6ddcb --- /dev/null +++ b/tests/test_scpc_result_methods.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd +import pandas.testing as pdt +import pytest + +from scpc.types import SCPCResult + + +def make_result(*, cvs: bool = False) -> SCPCResult: + return SCPCResult( + scpcstats=np.array( + [ + [1.0, 0.5, 2.0, 0.05, 0.0, 2.0], + [-1.0, 0.25, -4.0, 0.01, -1.5, -0.5], + ] + ), + scpccvs=np.array( + [ + [1.0, 1.5, 2.0, 3.0], + [1.1, 1.6, 2.1, 3.1], + ] + ) + if cvs + else None, + w=np.ones((3, 2)), + avc=0.03, + c0=1.2, + cv=2.0, + q=1, + coef_names=["Intercept", "x"], + method="exact", + large_n_seed=1, + ) + + +def test_repr_returns_compact_result_description() -> None: + assert repr(make_result()) == "SCPCResult(ncoef=2, q=1, avc=0.03, method='exact')" + + +def test_str_returns_main_inference_table() -> None: + text = str(make_result()) + + assert "SCPC Inference (ncoef = 2, q = 1)" in text + assert "Intercept" in text + assert "P>|t|" in text + assert "95% Confidence Intervals" not in text + + +def test_summary_returns_main_table_and_confidence_intervals() -> None: + text = make_result(cvs=True).summary() + + assert "SCPC Inference (ncoef = 2, q = 1, avc = 0.03)" in text + assert "95% Confidence Intervals:" in text + assert "Two-sided critical values:" in text + + +def test_coef_returns_named_coefficient_series() -> None: + pdt.assert_series_equal( + make_result().coef(), + pd.Series([1.0, -1.0], index=["Intercept", "x"], name="Coef"), + ) + + +def test_confint_returns_stored_95_percent_intervals() -> None: + pdt.assert_frame_equal( + make_result().confint(), + pd.DataFrame( + [[0.0, 2.0], [-1.5, -0.5]], + index=["Intercept", "x"], + columns=["2.5 %", "97.5 %"], + ), + ) + + +def test_confint_selects_by_name_and_index() -> None: + expected = pd.DataFrame( + [[-1.5, -0.5]], + index=["x"], + columns=["2.5 %", "97.5 %"], + ) + + pdt.assert_frame_equal(make_result().confint(parm="x"), expected) + pdt.assert_frame_equal(make_result().confint(parm=1), expected) + + +def test_confint_uses_stored_critical_values_for_non_95_levels() -> None: + pdt.assert_frame_equal( + make_result(cvs=True).confint(parm=["x"], level=0.90), + pd.DataFrame([[-1.4, -0.6]], index=["x"], columns=["5 %", "95 %"]), + ) + + +def test_confint_requires_stored_critical_values_for_non_95_levels() -> None: + with pytest.raises(ValueError, match="not available"): + make_result().confint(level=0.90) diff --git a/tests/utils.py b/tests/utils.py index 116589e..0099c4f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -75,9 +75,7 @@ def normalize_r_score_names(score_names: list[str], coef_names: list[str]) -> li normalized.append(coef_name) continue if score_name != coef_name: - raise ValueError( - "R score column names do not match the coefficient names." - ) + raise ValueError("R score column names do not match the coefficient names.") normalized.append(score_name) return normalized @@ -111,7 +109,9 @@ def make_basic_iv_data(seed: int, n: int = 120) -> pd.DataFrame: ) -def make_one_way_fe_iv_data(seed: int, n_fe: int = 20, t_per_fe: int = 5) -> pd.DataFrame: +def make_one_way_fe_iv_data( + seed: int, n_fe: int = 20, t_per_fe: int = 5 +) -> pd.DataFrame: """Build a one-way absorbed-fe IV dataset.""" rng = np.random.default_rng(seed) n = n_fe * t_per_fe @@ -146,9 +146,14 @@ def make_two_way_fe_iv_data( w = rng.normal(size=n) u = rng.normal(size=n) x = 0.7 * z + 0.2 * w + u - y = 1.0 + 1.1 * x + 0.35 * w + rng.normal(size=n1)[fe1 - 1] + rng.normal( - size=n2 - )[fe2 - 1] + u + y = ( + 1.0 + + 1.1 * x + + 0.35 * w + + rng.normal(size=n1)[fe1 - 1] + + rng.normal(size=n2)[fe2 - 1] + + u + ) return pd.DataFrame( { "y": y, From d7230d4c5d4de8fa614a08ca7c973b54ead139de Mon Sep 17 00:00:00 2001 From: DGoettlich Date: Thu, 23 Apr 2026 14:00:57 +0200 Subject: [PATCH 3/8] [scpc] examples for ols and iv --- examples/scpc_iv.py | 38 ++++++++++++++++++++++++++++++++++++++ examples/scpc_ols.py | 25 +++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 examples/scpc_iv.py create mode 100644 examples/scpc_ols.py diff --git a/examples/scpc_iv.py b/examples/scpc_iv.py new file mode 100644 index 0000000..f5347fd --- /dev/null +++ b/examples/scpc_iv.py @@ -0,0 +1,38 @@ +from __future__ import annotations +import numpy as np +import pandas as pd +import pyfixest as pf +import scpc + + +if __name__ == "__main__": + rng = np.random.default_rng(2001) + n = 120 + z = rng.normal(size=n) + w = rng.normal(size=n) + u = rng.normal(size=n) + x = 0.9 * z + 0.4 * w + 0.7 * u + rng.normal(scale=0.2, size=n) + y = 1.0 + 1.2 * x + 0.5 * w + u + data = pd.DataFrame( + { + "y": y, + "x": x, + "w": w, + "z": z, + "coord_x": rng.uniform(size=n), + "coord_y": rng.uniform(size=n), + } + ) + + fit = pf.feols("y ~ w | x ~ z", data=data) + + result = scpc.scpc( + fit, + data=data, + coords_euclidean=("coord_x", "coord_y"), + avc=0.1, + method="exact", + cvs=True, + ) + + print(result) diff --git a/examples/scpc_ols.py b/examples/scpc_ols.py new file mode 100644 index 0000000..b459362 --- /dev/null +++ b/examples/scpc_ols.py @@ -0,0 +1,25 @@ +from __future__ import annotations +import pandas as pd +import statsmodels.formula.api as smf +import scpc + + +if __name__ == "__main__": + data = pd.DataFrame( + { + "y": [1.0, 1.8, 2.9, 3.7, 5.1], + "x": [0.0, 1.0, 2.0, 3.0, 4.0], + "lat": [0.0, 1.0, 0.5, 1.5, 2.0], + "lon": [0.0, 0.0, 1.0, 1.0, 1.5], + } + ) + fit = smf.ols("y ~ x", data=data).fit() + + result = scpc.scpc( + fit, + data=data, + lat="lat", + lon="lon", + ) + + print(result) From dab01e016196e38cb37c94d6b35bea61bb142441 Mon Sep 17 00:00:00 2001 From: DGoettlich Date: Thu, 23 Apr 2026 14:05:24 +0200 Subject: [PATCH 4/8] [docs] updated docs for SCPCResult summary methods --- docs/index.md | 6 +++++- docs/reference.md | 10 +++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/index.md b/docs/index.md index 2345ab1..2e4ba5b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -51,7 +51,11 @@ out = scpc( lat="lat", ) -out.scpcstats +print(out) +print(out.summary()) +out.coef() +out.confint() +out.confint(parm="gini") ``` - `fit` is the fitted model diff --git a/docs/reference.md b/docs/reference.md index 8c2c11c..dfe6715 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -125,9 +125,9 @@ Returned by `scpc()`. - `c0` - `cv` - `q` +- `coef_names` - `method` - `large_n_seed` -- `call` **Notes** @@ -136,9 +136,5 @@ Returned by `scpc()`. upper bound - `method` records the spatial algorithm actually used: `"exact"` or `"approx"` -- the stable access path in the current package is through these stored arrays - and metadata fields - -The type also declares `__str__()`, `summary()`, `coef()`, and `confint()` -methods. In the current package state, the result object should be treated as a -field-oriented container rather than relying on those helpers. +- `coef()`, `confint()`, `str(result)`, and `summary()` provide named access + and formatted display helpers. From b50f38a5ca0c3af49a1f79b3f3e7c2cee8990ecf Mon Sep 17 00:00:00 2001 From: DGoettlich Date: Thu, 23 Apr 2026 14:20:01 +0200 Subject: [PATCH 5/8] [scpc] added typed dict for fixest specification to make ty happy --- src/scpc/types.py | 13 ++++++++++++- src/scpc/utils/data.py | 3 ++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/scpc/types.py b/src/scpc/types.py index 0dd6542..55a85a6 100644 --- a/src/scpc/types.py +++ b/src/scpc/types.py @@ -2,7 +2,7 @@ from collections.abc import Sequence from dataclasses import dataclass -from typing import Any, TypeAlias +from typing import Any, TypeAlias, TypedDict import numpy as np import pandas as pd @@ -19,6 +19,17 @@ SCPC_CV_LEVELS = {0.68: 0, 0.90: 1, 0.95: 2, 0.99: 3} +class FixestSpec(TypedDict): + """Stored pyfixest IV design objects aligned to coefficient order.""" + + X: MatrixLike + Z: MatrixLike + model_mat: MatrixLike + coef_names: list[str] + fixef_id: ArrayLike | None + has_fixef: bool + + @dataclass(slots=True) class CoordinateData: """Coordinates aligned to the active observations.""" diff --git a/src/scpc/utils/data.py b/src/scpc/utils/data.py index 36eba6e..cfd533f 100644 --- a/src/scpc/utils/data.py +++ b/src/scpc/utils/data.py @@ -8,6 +8,7 @@ ConditionalProjectionSetup, CoordinateData, DataFrameLike, + FixestSpec, MatrixLike, ModelLike, ) @@ -326,7 +327,7 @@ def has_fixest_fe(model: ModelLike) -> bool: def get_fixest_iv_design( model: ModelLike, -) -> dict[str, MatrixLike | list[str] | None | bool]: +) -> FixestSpec: """Extract the stored IV design objects from a pyfixest fit. This mirrors `scpcR:::.get_fixest_iv_design()`. In the R code that helper From ca51aa3349f0509941372ba87387e714167dea01 Mon Sep 17 00:00:00 2001 From: DGoettlich Date: Thu, 23 Apr 2026 14:31:49 +0200 Subject: [PATCH 6/8] [docs] updated docs --- docs/index.md | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/docs/index.md b/docs/index.md index 2e4ba5b..f5f1051 100644 --- a/docs/index.md +++ b/docs/index.md @@ -52,10 +52,6 @@ out = scpc( ) print(out) -print(out.summary()) -out.coef() -out.confint() -out.confint(parm="gini") ``` - `fit` is the fitted model @@ -65,9 +61,9 @@ out.confint(parm="gini") If your coordinates are Euclidean rather than geographic, use `coords_euclidean=[...]` instead of `lon` and `lat`. -`out.scpcstats` contains the main SCPC inference table. If you call -`scpc(..., cvs=True)`, additional critical values are stored in -`out.scpccvs`. +`print(out)` shows an R-like SCPC inference table. For named access, use +`out.coef()`, `out.confint()`, and `out.summary()`. The raw arrays remain +available as `out.scpcstats` and, when `cvs=True`, `out.scpccvs`. If you need the diagnostic and transformation stage before inference, the easiest entry point is `spur-python`, which uses `scpc-python` internally for @@ -89,11 +85,11 @@ result = spur( ) ``` -The `scpc` stats in the result object can be accessed using: +The nested SCPC results can be printed directly: ```python -result.fits.levels.scpc.scpcstats -result.fits.transformed.scpc.scpcstats +print(result.fits.levels.scpc) +print(result.fits.transformed.scpc) ``` ## Next Step From 477cca321d76415fbe933d949fc8af9cc7017f4c Mon Sep 17 00:00:00 2001 From: DGoettlich Date: Thu, 23 Apr 2026 14:32:24 +0200 Subject: [PATCH 7/8] [release] bumped to v0.1.2 --- README.md | 6 ++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 80833bd..bdd9bf9 100644 --- a/README.md +++ b/README.md @@ -69,13 +69,19 @@ result = scpc( lat="lat", cvs=True, ) + +print(result) ``` `scpc()` returns an `SCPCResult` object: +- `print(result)`: prints an R-like SCPC inference table - `result.scpcstats`: the main inference table with coefficient estimates, standard errors, t statistics, p values, and 95% interval endpoints - `result.scpccvs`: optional stored critical values at 32%, 10%, 5%, and 1% +- `result.coef()`: returns named coefficient estimates +- `result.confint()`: returns named confidence intervals +- `result.summary()`: prints the main table plus confidence intervals - `result.avc`: the average pairwise correlation bound used in the analysis - `result.c0`: the kernel scale implied by `avc` - `result.cv`: the unconditional 5% critical value diff --git a/pyproject.toml b/pyproject.toml index 75784f8..7f247d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "scpc-python" -version = "0.1.1" +version = "0.1.2" description = "SCPC inference in Python" readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 4f3c5f6..fa11287 100644 --- a/uv.lock +++ b/uv.lock @@ -1537,7 +1537,7 @@ wheels = [ [[package]] name = "scpc-python" -version = "0.1.0b5" +version = "0.1.2" source = { editable = "." } dependencies = [ { name = "numpy" }, From c360c75f83b663728808ebba6a4904b0dd356120 Mon Sep 17 00:00:00 2001 From: DGoettlich Date: Thu, 23 Apr 2026 14:36:01 +0200 Subject: [PATCH 8/8] [docs] clarified version access to summary methods --- README.md | 7 ++++--- docs/index.md | 7 ++++--- docs/reference.md | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index bdd9bf9..516fb1d 100644 --- a/README.md +++ b/README.md @@ -79,9 +79,10 @@ print(result) - `result.scpcstats`: the main inference table with coefficient estimates, standard errors, t statistics, p values, and 95% interval endpoints - `result.scpccvs`: optional stored critical values at 32%, 10%, 5%, and 1% -- `result.coef()`: returns named coefficient estimates -- `result.confint()`: returns named confidence intervals -- `result.summary()`: prints the main table plus confidence intervals +- `result.coef()`: returns named coefficient estimates in `scpc-python>=0.1.2` +- `result.confint()`: returns named confidence intervals in `scpc-python>=0.1.2` +- `result.summary()`: prints the main table plus confidence intervals in + `scpc-python>=0.1.2` - `result.avc`: the average pairwise correlation bound used in the analysis - `result.c0`: the kernel scale implied by `avc` - `result.cv`: the unconditional 5% critical value diff --git a/docs/index.md b/docs/index.md index f5f1051..bbcb9c2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -61,9 +61,10 @@ print(out) If your coordinates are Euclidean rather than geographic, use `coords_euclidean=[...]` instead of `lon` and `lat`. -`print(out)` shows an R-like SCPC inference table. For named access, use -`out.coef()`, `out.confint()`, and `out.summary()`. The raw arrays remain -available as `out.scpcstats` and, when `cvs=True`, `out.scpccvs`. +`print(out)` shows an R-like SCPC inference table. From +`scpc-python>=0.1.2`, use `out.coef()`, `out.confint()`, and `out.summary()` +for named access. The raw arrays remain available as `out.scpcstats` and, when +`cvs=True`, `out.scpccvs`. If you need the diagnostic and transformation stage before inference, the easiest entry point is `spur-python`, which uses `scpc-python` internally for diff --git a/docs/reference.md b/docs/reference.md index dfe6715..074a3f1 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -137,4 +137,4 @@ Returned by `scpc()`. - `method` records the spatial algorithm actually used: `"exact"` or `"approx"` - `coef()`, `confint()`, `str(result)`, and `summary()` provide named access - and formatted display helpers. + and formatted display helpers in `scpc-python>=0.1.2`.