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 +13 -0
- stratindex/_kernel.py +95 -0
- stratindex/_utils.py +128 -0
- stratindex/core.py +161 -0
- stratindex/data/cpsmarch2015.csv.gz +0 -0
- stratindex/datasets.py +56 -0
- stratindex/results.py +129 -0
- stratindex-0.1.0.dist-info/METADATA +121 -0
- stratindex-0.1.0.dist-info/RECORD +11 -0
- stratindex-0.1.0.dist-info/WHEEL +4 -0
- stratindex-0.1.0.dist-info/licenses/LICENSE +674 -0
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()
|