Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,20 @@ 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 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
Expand Down
15 changes: 8 additions & 7 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ out = scpc(
lat="lat",
)

out.scpcstats
print(out)
```

- `fit` is the fitted model
Expand All @@ -61,9 +61,10 @@ out.scpcstats
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. 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
Expand All @@ -85,11 +86,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
Expand Down
10 changes: 3 additions & 7 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,9 @@ Returned by `scpc()`.
- `c0`
- `cv`
- `q`
- `coef_names`
- `method`
- `large_n_seed`
- `call`

**Notes**

Expand All @@ -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 in `scpc-python>=0.1.2`.
38 changes: 38 additions & 0 deletions examples/scpc_iv.py
Original file line number Diff line number Diff line change
@@ -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)
25 changes: 25 additions & 0 deletions examples/scpc_ols.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
8 changes: 5 additions & 3 deletions src/scpc/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
108 changes: 98 additions & 10 deletions src/scpc/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,33 @@

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

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}


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:
Expand Down Expand Up @@ -82,12 +102,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.
Expand All @@ -99,7 +119,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.
Expand All @@ -111,7 +134,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.
Expand All @@ -123,7 +163,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.
Expand All @@ -135,11 +198,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.
Expand All @@ -159,4 +223,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} %"],
)
Loading
Loading