stratindex 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
stratindex/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """stratindex: a nonparametric index of stratification (Zhou 2012).
2
+
3
+ Python port of the R package 'strat' by Xiang Zhou
4
+ (https://cran.r-project.org/package=strat).
5
+ """
6
+
7
+ from .core import srank, strat
8
+ from .datasets import load_cpsmarch2015
9
+ from .results import SrankResult, StratResult
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = ["srank", "strat", "SrankResult", "StratResult", "load_cpsmarch2015", "__version__"]
stratindex/_kernel.py ADDED
@@ -0,0 +1,95 @@
1
+ """Pairwise-comparison kernels.
2
+
3
+ Blocked NumPy ports of ``strat_cpp`` and ``strat_cpp_by`` from
4
+ ``src/strat_cpp.cpp`` in the R package. Inputs must be sorted ascending by
5
+ ``r``. A pair (i, j), i < j, contributes ``w_i * w_j`` to the denominator and
6
+ ``sign(y_j - y_i) * w_i * w_j`` to the numerator, unless ``r_j == r_i`` (same
7
+ stratum position) or ``y_j == y_i`` (tied outcome), in which case it is
8
+ skipped.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+
15
+ _BLOCK = 2048
16
+
17
+
18
+ def pair_sums(
19
+ y: np.ndarray, r: np.ndarray, w: np.ndarray, block: int = _BLOCK
20
+ ) -> tuple[float, float]:
21
+ """Return ``(deno, nume)`` summed over all valid pairs."""
22
+ n = y.size
23
+ deno = 0.0
24
+ nume = 0.0
25
+ for i0 in range(0, n, block):
26
+ i1 = min(i0 + block, n)
27
+ yi = y[i0:i1, None]
28
+ ri = r[i0:i1, None]
29
+ wi = w[i0:i1, None]
30
+ for j0 in range(i0, n, block):
31
+ j1 = min(j0 + block, n)
32
+ yj = y[None, j0:j1]
33
+ rj = r[None, j0:j1]
34
+ valid = (rj > ri) & (yj != yi)
35
+ if j0 == i0:
36
+ valid &= np.arange(j0, j1)[None, :] > np.arange(i0, i1)[:, None]
37
+ wij = wi * w[None, j0:j1] * valid
38
+ deno += wij.sum()
39
+ nume += (np.sign(yj - yi) * wij).sum()
40
+ return deno, nume
41
+
42
+
43
+ def pair_sums_by(
44
+ y: np.ndarray,
45
+ r: np.ndarray,
46
+ w: np.ndarray,
47
+ c: np.ndarray,
48
+ n_groups: int,
49
+ block: int = _BLOCK,
50
+ ) -> dict[str, np.ndarray | float]:
51
+ """Group decomposition of the pairwise sums.
52
+
53
+ Pairs within the same group accumulate into per-group ``deno_by`` /
54
+ ``nume_by`` (indexed by the group code of both members); pairs across
55
+ groups accumulate into the between-group sums.
56
+ """
57
+ n = y.size
58
+ deno_by = np.zeros(n_groups)
59
+ nume_by = np.zeros(n_groups)
60
+ deno_between = 0.0
61
+ nume_between = 0.0
62
+ for i0 in range(0, n, block):
63
+ i1 = min(i0 + block, n)
64
+ yi = y[i0:i1, None]
65
+ ri = r[i0:i1, None]
66
+ wi = w[i0:i1, None]
67
+ ci = c[i0:i1]
68
+ for j0 in range(i0, n, block):
69
+ j1 = min(j0 + block, n)
70
+ yj = y[None, j0:j1]
71
+ rj = r[None, j0:j1]
72
+ valid = (rj > ri) & (yj != yi)
73
+ if j0 == i0:
74
+ valid &= np.arange(j0, j1)[None, :] > np.arange(i0, i1)[:, None]
75
+ wij = wi * w[None, j0:j1] * valid
76
+ s = np.sign(yj - yi) * wij
77
+ same = c[None, j0:j1] == ci[:, None]
78
+ w_same = np.where(same, wij, 0.0)
79
+ s_same = np.where(same, s, 0.0)
80
+ deno_by += np.bincount(ci, weights=w_same.sum(axis=1), minlength=n_groups)
81
+ nume_by += np.bincount(ci, weights=s_same.sum(axis=1), minlength=n_groups)
82
+ # element-wise differences are exact where same-group entries cancel
83
+ deno_between += (wij - w_same).sum()
84
+ nume_between += (s - s_same).sum()
85
+
86
+ deno_within = deno_by.sum()
87
+ nume_within = nume_by.sum()
88
+ return {
89
+ "deno_by": deno_by,
90
+ "nume_by": nume_by,
91
+ "deno_within": deno_within,
92
+ "nume_within": nume_within,
93
+ "deno_between": deno_between,
94
+ "nume_between": nume_between,
95
+ }
stratindex/_utils.py ADDED
@@ -0,0 +1,128 @@
1
+ """Input cleaning and weighted percentile ranks.
2
+
3
+ Ports the internal helpers of the R package ``strat`` (``R/utils.R``):
4
+ ``wtd_rank`` (a thin wrapper around ``Hmisc::wtd.rank``) and ``clean``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from dataclasses import dataclass
11
+
12
+ import numpy as np
13
+
14
+
15
+ def wtd_rank(x: np.ndarray, weights: np.ndarray | None = None) -> np.ndarray:
16
+ """Weighted midranks of ``x``.
17
+
18
+ Equivalent to ``Hmisc::wtd.rank(x, weights, normwt = TRUE)``: weights are
19
+ normalized to sum to ``len(x)``, tied values share the midrank of their
20
+ weight block.
21
+ """
22
+ x = np.asarray(x, dtype=float)
23
+ if weights is None:
24
+ w = np.ones_like(x)
25
+ else:
26
+ w = np.asarray(weights, dtype=float)
27
+ w = w / w.sum() * x.size # normwt = TRUE
28
+ _, inverse = np.unique(x, return_inverse=True)
29
+ freqs = np.bincount(inverse, weights=w)
30
+ ranks = np.cumsum(freqs) - 0.5 * (freqs - 1.0)
31
+ return ranks[inverse]
32
+
33
+
34
+ def _is_na(arr: np.ndarray) -> np.ndarray:
35
+ if arr.dtype.kind == "f":
36
+ return np.isnan(arr)
37
+ if arr.dtype.kind == "O":
38
+ return np.array(
39
+ [v is None or (isinstance(v, float) and math.isnan(v)) for v in arr],
40
+ dtype=bool,
41
+ )
42
+ return np.zeros(arr.shape, dtype=bool)
43
+
44
+
45
+ @dataclass
46
+ class CleanData:
47
+ """Complete cases of all inputs, ready for the pairwise kernel.
48
+
49
+ Mirrors the data frame returned by ``clean()`` in the R package: ``prank``
50
+ is the weighted percentile rank in (0, 1], ``weights`` are normalized to
51
+ sum to ``n``, strata and group are stored as integer codes plus levels
52
+ (sorted unique values, as ``factor()`` does).
53
+ """
54
+
55
+ prank: np.ndarray
56
+ strata_codes: np.ndarray
57
+ strata_levels: np.ndarray
58
+ weights: np.ndarray
59
+ group_codes: np.ndarray | None
60
+ group_levels: np.ndarray | None
61
+ n: int
62
+
63
+
64
+ def clean(
65
+ outcome,
66
+ strata,
67
+ weights=None,
68
+ group=None,
69
+ ) -> CleanData:
70
+ """Validate inputs, drop incomplete cases, compute weighted percentile ranks."""
71
+ outcome = np.asarray(outcome)
72
+ if (
73
+ outcome.ndim != 1
74
+ or outcome.size == 0
75
+ or not np.issubdtype(outcome.dtype, np.number)
76
+ or outcome.dtype.kind == "b"
77
+ ):
78
+ raise ValueError("outcome has to be a non-empty numeric 1-d array")
79
+ outcome = outcome.astype(float)
80
+
81
+ strata = np.asarray(strata)
82
+ if strata.ndim != 1 or len(strata) != len(outcome):
83
+ raise ValueError("outcome and strata have to be of equal length")
84
+
85
+ if weights is None:
86
+ weights_arr = np.ones(outcome.size)
87
+ else:
88
+ weights_arr = np.asarray(weights)
89
+ if weights_arr.ndim != 1 or not np.issubdtype(weights_arr.dtype, np.number):
90
+ raise ValueError("weights has to be a numeric 1-d array")
91
+ if len(weights_arr) != len(outcome):
92
+ raise ValueError("outcome and weights have to be of equal length")
93
+ weights_arr = weights_arr.astype(float)
94
+
95
+ group_arr: np.ndarray | None = None
96
+ if group is not None:
97
+ group_arr = np.asarray(group)
98
+ if group_arr.ndim != 1 or len(group_arr) != len(outcome):
99
+ raise ValueError("outcome and group have to be of equal length")
100
+ # R's strat() rejects missing values in group up front
101
+ if _is_na(group_arr).any():
102
+ raise ValueError("group contains missing values")
103
+
104
+ ok = ~(_is_na(outcome) | _is_na(strata) | _is_na(weights_arr))
105
+ n = int(ok.sum())
106
+ if n == 0:
107
+ raise ValueError("no complete cases!")
108
+
109
+ outcome = outcome[ok]
110
+ weights_arr = weights_arr[ok]
111
+ weights_arr = weights_arr / weights_arr.sum() * n
112
+ strata_levels, strata_codes = np.unique(strata[ok], return_inverse=True)
113
+
114
+ group_codes = group_levels = None
115
+ if group_arr is not None:
116
+ group_levels, group_codes = np.unique(group_arr[ok], return_inverse=True)
117
+
118
+ prank = wtd_rank(outcome, weights_arr) / n
119
+
120
+ return CleanData(
121
+ prank=prank,
122
+ strata_codes=strata_codes,
123
+ strata_levels=strata_levels,
124
+ weights=weights_arr,
125
+ group_codes=group_codes,
126
+ group_levels=group_levels,
127
+ n=n,
128
+ )
stratindex/core.py ADDED
@@ -0,0 +1,161 @@
1
+ """Public API: :func:`srank` and :func:`strat`.
2
+
3
+ Port of ``R/srank.R`` and ``R/strat.R`` from the R package ``strat``
4
+ (Xiang Zhou, https://cran.r-project.org/package=strat).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+ from ._kernel import pair_sums, pair_sums_by
12
+ from ._utils import CleanData, clean
13
+ from .results import SrankResult, StratResult
14
+
15
+ __all__ = ["srank", "strat"]
16
+
17
+
18
+ def _summarize(cd: CleanData) -> dict[str, np.ndarray]:
19
+ """Per-stratum population share and average percentile rank."""
20
+ k = len(cd.strata_levels)
21
+ w_by_stratum = np.bincount(cd.strata_codes, weights=cd.weights, minlength=k)
22
+ share = w_by_stratum / cd.weights.sum()
23
+ s_prank = (
24
+ np.bincount(cd.strata_codes, weights=cd.weights * cd.prank, minlength=k) / w_by_stratum
25
+ )
26
+ return {"strata": cd.strata_levels, "share": share, "s_prank": s_prank}
27
+
28
+
29
+ def _raw(cd: CleanData) -> dict[str, np.ndarray]:
30
+ raw = {
31
+ "prank": cd.prank,
32
+ "strata": cd.strata_levels[cd.strata_codes],
33
+ "weights": cd.weights,
34
+ }
35
+ if cd.group_codes is not None:
36
+ raw["group"] = cd.group_levels[cd.group_codes]
37
+ return raw
38
+
39
+
40
+ def srank(outcome, strata, weights=None, group=None) -> SrankResult:
41
+ """Rank strata by the average percentile rank of their members.
42
+
43
+ Parameters
44
+ ----------
45
+ outcome:
46
+ Numeric array of outcomes.
47
+ strata:
48
+ Array of the same length indicating strata membership.
49
+ weights:
50
+ Optional numeric array of sampling weights.
51
+ group:
52
+ Optional grouping factor, carried through to ``raw``.
53
+
54
+ Returns
55
+ -------
56
+ SrankResult
57
+ ``raw`` (complete cases with percentile ranks) and ``summary``
58
+ (per-stratum share and average percentile rank).
59
+ """
60
+ cd = clean(outcome, strata, weights=weights, group=group)
61
+ return SrankResult(raw=_raw(cd), summary=_summarize(cd))
62
+
63
+
64
+ def strat(
65
+ outcome,
66
+ strata,
67
+ weights=None,
68
+ ordered: bool = False,
69
+ group=None,
70
+ group_name: str = "group",
71
+ ) -> StratResult:
72
+ """Compute the stratification index proposed in Zhou (2012).
73
+
74
+ Parameters
75
+ ----------
76
+ outcome:
77
+ Numeric array of outcomes.
78
+ strata:
79
+ Array of the same length indicating strata membership.
80
+ weights:
81
+ Optional numeric array of sampling weights.
82
+ ordered:
83
+ If True, strata are taken as pre-ordered ascendingly (by their sorted
84
+ unique values); otherwise they are ordered by average percentile rank.
85
+ group:
86
+ Optional grouping factor. If supplied (with more than one level), the
87
+ result includes a between-/within-group decomposition of the overall
88
+ stratification.
89
+ group_name:
90
+ Label used for the group in printed output (R derives it from the
91
+ expression passed as ``group``; Python cannot, so pass it explicitly).
92
+
93
+ Returns
94
+ -------
95
+ StratResult
96
+ Overall index with approximate standard error (Goodman & Kruskal
97
+ 1963), per-stratum information, and the group decomposition when a
98
+ group is supplied.
99
+
100
+ References
101
+ ----------
102
+ Zhou, Xiang. 2012. "A Nonparametric Index of Stratification."
103
+ Sociological Methodology, 42(1): 365-389.
104
+ """
105
+ if not isinstance(ordered, bool):
106
+ raise ValueError("ordered has to be a valid logical scalar")
107
+
108
+ cd = clean(outcome, strata, weights=weights, group=group)
109
+ strata_info = _summarize(cd)
110
+
111
+ row_s_prank = strata_info["s_prank"][cd.strata_codes]
112
+ if ordered:
113
+ sort_by = cd.strata_codes.astype(float)
114
+ else:
115
+ sort_by = row_s_prank
116
+ order = np.argsort(sort_by, kind="stable")
117
+ y = cd.prank[order]
118
+ r = sort_by[order]
119
+ w = cd.weights[order]
120
+
121
+ decomposition = None
122
+ within_group = None
123
+ if cd.group_codes is None or len(cd.group_levels) == 1:
124
+ deno, nume = pair_sums(y, r, w)
125
+ with np.errstate(invalid="ignore"):
126
+ index = nume / deno
127
+ else:
128
+ c = cd.group_codes[order]
129
+ sums = pair_sums_by(y, r, w, c, len(cd.group_levels))
130
+ deno = sums["deno_within"] + sums["deno_between"]
131
+ with np.errstate(invalid="ignore", divide="ignore"):
132
+ index = (sums["nume_within"] + sums["nume_between"]) / deno
133
+ decomposition = {
134
+ "within": {
135
+ "weight": sums["deno_within"] / deno,
136
+ "strat": sums["nume_within"] / sums["deno_within"],
137
+ },
138
+ "between": {
139
+ "weight": sums["deno_between"] / deno,
140
+ "strat": sums["nume_between"] / sums["deno_between"],
141
+ },
142
+ }
143
+ within_group = {
144
+ group_name: cd.group_levels,
145
+ "weight": sums["deno_by"] / sums["deno_within"],
146
+ "strat": sums["nume_by"] / sums["deno_by"],
147
+ }
148
+
149
+ # approximate standard error (Goodman & Kruskal 1963)
150
+ with np.errstate(invalid="ignore", divide="ignore"):
151
+ arg = deno / (1.0 - index**2) / cd.n
152
+ std_error = 1.0 / np.sqrt(arg)
153
+
154
+ return StratResult(
155
+ strat=float(index),
156
+ std_error=float(std_error),
157
+ strata_info=strata_info,
158
+ decomposition=decomposition,
159
+ within_group=within_group,
160
+ group_name=group_name,
161
+ )
Binary file
stratindex/datasets.py ADDED
@@ -0,0 +1,56 @@
1
+ """Bundled example dataset.
2
+
3
+ ``cpsmarch2015`` is the dataset shipped with the R package ``strat``: income,
4
+ big class, microclass, education and sampling weight for 14,358 male
5
+ respondents aged 35-64 from the March 2015 Current Population Survey.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import csv
11
+ import gzip
12
+ from importlib.resources import files
13
+
14
+ import numpy as np
15
+
16
+ __all__ = ["load_cpsmarch2015"]
17
+
18
+ _COLUMNS = ("income", "big_class", "micro_class", "education", "weight")
19
+
20
+
21
+ def load_cpsmarch2015(as_pandas: bool = False):
22
+ """Load the cpsmarch2015 dataset.
23
+
24
+ Parameters
25
+ ----------
26
+ as_pandas:
27
+ If True, return a pandas DataFrame (requires pandas); otherwise a
28
+ dict of NumPy arrays keyed by column name.
29
+
30
+ Returns
31
+ -------
32
+ dict[str, numpy.ndarray] | pandas.DataFrame
33
+ Columns: ``income`` (float, personal market income in US dollars),
34
+ ``big_class`` (str), ``micro_class`` (int), ``education`` (str),
35
+ ``weight`` (float, CPS sampling weight).
36
+ """
37
+ resource = files("stratindex.data").joinpath("cpsmarch2015.csv.gz")
38
+ with resource.open("rb") as raw, gzip.open(raw, "rt", newline="") as fh:
39
+ reader = csv.reader(fh)
40
+ header = tuple(next(reader))
41
+ assert header == _COLUMNS
42
+ rows = list(reader)
43
+
44
+ columns = list(zip(*rows, strict=True))
45
+ data = {
46
+ "income": np.array(columns[0], dtype=float),
47
+ "big_class": np.array(columns[1]),
48
+ "micro_class": np.array(columns[2], dtype=int),
49
+ "education": np.array(columns[3]),
50
+ "weight": np.array(columns[4], dtype=float),
51
+ }
52
+ if as_pandas:
53
+ import pandas as pd
54
+
55
+ return pd.DataFrame(data)
56
+ return data
stratindex/results.py ADDED
@@ -0,0 +1,129 @@
1
+ """Result containers for :func:`stratindex.strat` and :func:`stratindex.srank`.
2
+
3
+ The ``__str__`` output mirrors the ``print.strat`` / ``print.srank`` methods
4
+ of the R package.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ import numpy as np
12
+
13
+
14
+ def _fmt(value: float, digits: int) -> str:
15
+ if isinstance(value, float) and np.isnan(value):
16
+ return "nan"
17
+ return f"{value:.{digits}g}"
18
+
19
+
20
+ def _table(columns: dict[str, list | np.ndarray], digits: int) -> str:
21
+ """Render a dict of equal-length columns as an aligned text table."""
22
+ cells: dict[str, list[str]] = {}
23
+ for name, col in columns.items():
24
+ cells[name] = [
25
+ _fmt(float(v), digits) if isinstance(v, (int, float, np.floating)) else str(v)
26
+ for v in col
27
+ ]
28
+ widths = {name: max(len(name), *(len(c) for c in col)) for name, col in cells.items()}
29
+ lines = [" ".join(name.rjust(widths[name]) for name in cells)]
30
+ n_rows = len(next(iter(cells.values())))
31
+ for i in range(n_rows):
32
+ lines.append(" ".join(cells[name][i].rjust(widths[name]) for name in cells))
33
+ return "\n".join(lines)
34
+
35
+
36
+ @dataclass
37
+ class SrankResult:
38
+ """Stratum-specific information: population share and average percentile rank.
39
+
40
+ Attributes
41
+ ----------
42
+ raw:
43
+ Complete cases of all inputs — dict with ``prank``, ``strata``,
44
+ ``weights`` (and ``group`` when supplied), each an ndarray of length n.
45
+ summary:
46
+ Per-stratum table — dict with ``strata`` (level labels), ``share``
47
+ and ``s_prank`` arrays.
48
+ """
49
+
50
+ raw: dict[str, np.ndarray] = field(repr=False)
51
+ summary: dict[str, np.ndarray]
52
+
53
+ def to_pandas(self):
54
+ """Return ``(raw, summary)`` as pandas DataFrames (requires pandas)."""
55
+ import pandas as pd
56
+
57
+ return pd.DataFrame(self.raw), pd.DataFrame(self.summary)
58
+
59
+ def format(self, digits: int = 3) -> str:
60
+ return _table(self.summary, digits)
61
+
62
+ def __str__(self) -> str:
63
+ return self.format()
64
+
65
+
66
+ @dataclass
67
+ class StratResult:
68
+ """The stratification index and its approximate standard error.
69
+
70
+ Attributes
71
+ ----------
72
+ strat:
73
+ The overall stratification index.
74
+ std_error:
75
+ Approximate standard error (Goodman & Kruskal 1963).
76
+ strata_info:
77
+ Per-stratum table — dict with ``strata``, ``share`` and ``s_prank``.
78
+ decomposition:
79
+ ``None`` unless a group was supplied; otherwise a dict with rows
80
+ ``within`` / ``between``, each a dict with ``weight`` and ``strat``.
81
+ within_group:
82
+ ``None`` unless a group was supplied; otherwise a dict with the group
83
+ levels (keyed by ``group_name``) and per-group ``weight`` / ``strat``
84
+ arrays.
85
+ """
86
+
87
+ strat: float
88
+ std_error: float
89
+ strata_info: dict[str, np.ndarray]
90
+ decomposition: dict[str, dict[str, float]] | None = None
91
+ within_group: dict[str, np.ndarray] | None = None
92
+ group_name: str = "group"
93
+
94
+ @property
95
+ def overall(self) -> dict[str, float]:
96
+ return {"strat": self.strat, "std_error": self.std_error}
97
+
98
+ def to_pandas(self):
99
+ """Return ``strata_info`` (and ``within_group`` if any) as DataFrames."""
100
+ import pandas as pd
101
+
102
+ strata_info = pd.DataFrame(self.strata_info)
103
+ if self.within_group is None:
104
+ return strata_info
105
+ return strata_info, pd.DataFrame(self.within_group)
106
+
107
+ def format(self, digits: int = 3) -> str:
108
+ lines = [
109
+ "overall stratification:",
110
+ "",
111
+ _table({"strat": [self.strat], "std_error": [self.std_error]}, digits),
112
+ ]
113
+ if self.decomposition is not None:
114
+ rows = {
115
+ "": [f"within {self.group_name}", f"between {self.group_name}"],
116
+ "weight": [
117
+ self.decomposition["within"]["weight"],
118
+ self.decomposition["between"]["weight"],
119
+ ],
120
+ "strat": [
121
+ self.decomposition["within"]["strat"],
122
+ self.decomposition["between"]["strat"],
123
+ ],
124
+ }
125
+ lines += ["", f"decomposition by {self.group_name}:", "", _table(rows, digits)]
126
+ return "\n".join(lines)
127
+
128
+ def __str__(self) -> str:
129
+ return self.format()