finite-selection 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.
- finite_selection/__init__.py +45 -0
- finite_selection/core.py +365 -0
- finite_selection/data/lalonde.parquet +0 -0
- finite_selection/datasets.py +62 -0
- finite_selection/features.py +56 -0
- finite_selection/inference.py +380 -0
- finite_selection/plot.py +109 -0
- finite_selection/py.typed +0 -0
- finite_selection/som.py +146 -0
- finite_selection-0.1.0.dist-info/METADATA +168 -0
- finite_selection-0.1.0.dist-info/RECORD +13 -0
- finite_selection-0.1.0.dist-info/WHEEL +4 -0
- finite_selection-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Finite Selection Model: randomized, balanced allocation of units to groups."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .core import FSMResult, fsm, fsm_batch
|
|
6
|
+
from .datasets import lalonde
|
|
7
|
+
from .features import make_sq_inter
|
|
8
|
+
from .inference import (
|
|
9
|
+
PermTestResult,
|
|
10
|
+
TasmdSummary,
|
|
11
|
+
crd,
|
|
12
|
+
ess_model,
|
|
13
|
+
ess_rand,
|
|
14
|
+
perm_test,
|
|
15
|
+
tasmd,
|
|
16
|
+
tasmd_rand,
|
|
17
|
+
)
|
|
18
|
+
from .plot import asmd, love_plot
|
|
19
|
+
from .som import SelectionOrder, scomars_probs, som
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
__version__ = version("finite-selection")
|
|
23
|
+
except PackageNotFoundError: # pragma: no cover - not installed
|
|
24
|
+
__version__ = "0.0.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"FSMResult",
|
|
28
|
+
"PermTestResult",
|
|
29
|
+
"SelectionOrder",
|
|
30
|
+
"TasmdSummary",
|
|
31
|
+
"asmd",
|
|
32
|
+
"crd",
|
|
33
|
+
"ess_model",
|
|
34
|
+
"ess_rand",
|
|
35
|
+
"fsm",
|
|
36
|
+
"fsm_batch",
|
|
37
|
+
"lalonde",
|
|
38
|
+
"love_plot",
|
|
39
|
+
"make_sq_inter",
|
|
40
|
+
"perm_test",
|
|
41
|
+
"scomars_probs",
|
|
42
|
+
"som",
|
|
43
|
+
"tasmd",
|
|
44
|
+
"tasmd_rand",
|
|
45
|
+
]
|
finite_selection/core.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""The Finite Selection Model allocation loop.
|
|
2
|
+
|
|
3
|
+
Groups take turns, in the order a :class:`~finite_selection.som.SelectionOrder`
|
|
4
|
+
gives, and at each turn the choosing group takes the remaining unit that
|
|
5
|
+
scores highest on a selection function (Morris 1979; Chattopadhyay, Morris
|
|
6
|
+
and Zubizarreta 2021). Numerical conventions follow the R package ``FSM``
|
|
7
|
+
1.0.0 so that allocations agree unit for unit; ``docs/behavior.md`` lists
|
|
8
|
+
each convention and the reference fixture that pins it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from scipy.linalg import lapack
|
|
18
|
+
|
|
19
|
+
from .som import SelectionOrder
|
|
20
|
+
|
|
21
|
+
SelectionFunction = Literal[
|
|
22
|
+
"constant",
|
|
23
|
+
"dopt",
|
|
24
|
+
"aopt",
|
|
25
|
+
"negative_dopt",
|
|
26
|
+
"max_pc",
|
|
27
|
+
"min_pc",
|
|
28
|
+
"dopt_pc",
|
|
29
|
+
"max_average",
|
|
30
|
+
"min_average",
|
|
31
|
+
"dopt_average",
|
|
32
|
+
"marginal_var_sum",
|
|
33
|
+
]
|
|
34
|
+
_MATRIX_FUNCTIONS = {"dopt", "aopt", "negative_dopt"}
|
|
35
|
+
|
|
36
|
+
# A group's information matrix counts as ill-conditioned below this reciprocal
|
|
37
|
+
# condition number, and is then ridged with eps * Q0 / N.
|
|
38
|
+
_RCOND_FLOOR = 1e-6
|
|
39
|
+
# Criteria are rounded before the argmax so near-ties resolve by tie rule
|
|
40
|
+
# rather than by floating-point noise.
|
|
41
|
+
_CRITERION_DIGITS = 7
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class FSMResult:
|
|
46
|
+
"""Outcome of an FSM allocation.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
assignment: Group label of each unit, in input order.
|
|
50
|
+
selected: Unit chosen at each stage (0-based row positions).
|
|
51
|
+
criteria: Stage-by-unit matrix of selection-function values, NaN for
|
|
52
|
+
units no longer available; ``None`` unless requested.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
assignment: np.ndarray
|
|
56
|
+
selected: np.ndarray
|
|
57
|
+
criteria: np.ndarray | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _standardize(x: np.ndarray) -> np.ndarray:
|
|
61
|
+
return (x - x.mean(axis=0)) / x.std(axis=0, ddof=1)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _rcond_1norm(m: np.ndarray) -> float:
|
|
65
|
+
"""LAPACK's 1-norm reciprocal condition estimate of the R factor of ``m``.
|
|
66
|
+
|
|
67
|
+
The estimate, not the exact value, is what the regularization switch is
|
|
68
|
+
defined on, so near the threshold the two can pick different branches.
|
|
69
|
+
"""
|
|
70
|
+
r = np.linalg.qr(m, mode="r")
|
|
71
|
+
rcond, info = lapack.dtrcon( # pyright: ignore[reportAttributeAccessIssue]
|
|
72
|
+
r, norm="1", uplo="U", diag="N"
|
|
73
|
+
)
|
|
74
|
+
return float(rcond) if info == 0 else 0.0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _group_inverse(
|
|
78
|
+
gram: np.ndarray, count: int, stage: int, ridge: np.ndarray
|
|
79
|
+
) -> np.ndarray:
|
|
80
|
+
if count == 0:
|
|
81
|
+
return np.linalg.inv(ridge)
|
|
82
|
+
if _rcond_1norm(gram / stage) < _RCOND_FLOOR:
|
|
83
|
+
return np.linalg.inv(gram / count + ridge)
|
|
84
|
+
return np.linalg.inv(gram)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _spread(values: np.ndarray, members: np.ndarray, avail: np.ndarray) -> np.ndarray:
|
|
88
|
+
"""Sum of squared deviations of ``members`` plus each candidate."""
|
|
89
|
+
base = values[members]
|
|
90
|
+
total = base.sum()
|
|
91
|
+
total_sq = (base**2).sum()
|
|
92
|
+
m = len(base) + 1
|
|
93
|
+
cand = values[avail]
|
|
94
|
+
return total_sq + cand**2 - (total + cand) ** 2 / m
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _marginal_var_sum(
|
|
98
|
+
x: np.ndarray, members: np.ndarray, avail: np.ndarray
|
|
99
|
+
) -> np.ndarray:
|
|
100
|
+
if x.shape[1] == 1:
|
|
101
|
+
# R reports the sum of squares, not the variance, for one covariate.
|
|
102
|
+
# Every candidate at a stage shares the group size, so the choice is
|
|
103
|
+
# the same either way; only the reported criterion differs.
|
|
104
|
+
return _spread(x[:, 0], members, avail)
|
|
105
|
+
if len(members) == 0:
|
|
106
|
+
return np.zeros(len(avail))
|
|
107
|
+
return np.array(
|
|
108
|
+
[
|
|
109
|
+
np.trace(np.atleast_2d(np.cov(x[np.append(members, u)], rowvar=False)))
|
|
110
|
+
for u in avail
|
|
111
|
+
]
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _first_pc(x: np.ndarray) -> np.ndarray:
|
|
116
|
+
centered = x - x.mean(axis=0)
|
|
117
|
+
_, _, vt = np.linalg.svd(centered, full_matrices=False)
|
|
118
|
+
return centered @ vt[0]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def fsm(
|
|
122
|
+
x: np.ndarray,
|
|
123
|
+
order: SelectionOrder | np.ndarray,
|
|
124
|
+
*,
|
|
125
|
+
s_function: SelectionFunction = "dopt",
|
|
126
|
+
q_initial: np.ndarray | None = None,
|
|
127
|
+
eps: float = 1e-3,
|
|
128
|
+
ties: Literal["random", "smallest"] = "random",
|
|
129
|
+
intercept: bool = True,
|
|
130
|
+
standardize: bool = True,
|
|
131
|
+
pol_mat: np.ndarray | None = None,
|
|
132
|
+
w_pol: np.ndarray | None = None,
|
|
133
|
+
keep_criteria: bool = False,
|
|
134
|
+
rng: np.random.Generator | None = None,
|
|
135
|
+
) -> FSMResult:
|
|
136
|
+
"""Allocate units to groups with the Finite Selection Model.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
x: Covariates, one row per unit.
|
|
140
|
+
order: Selection order; ``order[i]`` is the group choosing at stage i.
|
|
141
|
+
Must have one stage per unit.
|
|
142
|
+
s_function: Selection function. ``"dopt"`` (D-optimality) picks the
|
|
143
|
+
unit that most increases the determinant of the choosing group's
|
|
144
|
+
information matrix.
|
|
145
|
+
q_initial: Matrix used in place of the full-sample X'X to ridge an
|
|
146
|
+
ill-conditioned group information matrix.
|
|
147
|
+
eps: Ridge scale; the ridge is ``eps * Q0 / N``.
|
|
148
|
+
ties: Pick a random maximizer, or the earliest-listed one.
|
|
149
|
+
intercept: Include an intercept column in the design matrix.
|
|
150
|
+
standardize: Standardize covariates (sample SD) before selection.
|
|
151
|
+
pol_mat: Policy matrix for ``"aopt"``; one column per design column.
|
|
152
|
+
w_pol: Policy weights for ``"aopt"``.
|
|
153
|
+
keep_criteria: Also return the stage-by-unit criterion matrix.
|
|
154
|
+
rng: Random generator for tie-breaking.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
The allocation.
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
ValueError: If the order does not have one stage per unit, or the
|
|
161
|
+
covariates are collinear and no ``q_initial`` is given.
|
|
162
|
+
"""
|
|
163
|
+
x_cov = np.asarray(x, dtype=float)
|
|
164
|
+
if x_cov.ndim == 1:
|
|
165
|
+
x_cov = x_cov[:, None]
|
|
166
|
+
labels = np.asarray(order.treat if isinstance(order, SelectionOrder) else order)
|
|
167
|
+
n = len(x_cov)
|
|
168
|
+
if len(labels) != n:
|
|
169
|
+
raise ValueError("the selection order needs one stage per unit")
|
|
170
|
+
return _allocate(
|
|
171
|
+
x_cov,
|
|
172
|
+
labels,
|
|
173
|
+
past=None,
|
|
174
|
+
s_function=s_function,
|
|
175
|
+
q_initial=q_initial,
|
|
176
|
+
eps=eps,
|
|
177
|
+
ties=ties,
|
|
178
|
+
intercept=intercept,
|
|
179
|
+
standardize=standardize,
|
|
180
|
+
pol_mat=pol_mat,
|
|
181
|
+
w_pol=w_pol,
|
|
182
|
+
keep_criteria=keep_criteria,
|
|
183
|
+
rng=rng or np.random.default_rng(),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _allocate(
|
|
188
|
+
x_cov: np.ndarray,
|
|
189
|
+
labels: np.ndarray,
|
|
190
|
+
*,
|
|
191
|
+
past: tuple[np.ndarray, np.ndarray] | None,
|
|
192
|
+
s_function: str,
|
|
193
|
+
q_initial: np.ndarray | None,
|
|
194
|
+
eps: float,
|
|
195
|
+
ties: str,
|
|
196
|
+
intercept: bool,
|
|
197
|
+
standardize: bool,
|
|
198
|
+
pol_mat: np.ndarray | None,
|
|
199
|
+
w_pol: np.ndarray | None,
|
|
200
|
+
keep_criteria: bool,
|
|
201
|
+
rng: np.random.Generator,
|
|
202
|
+
) -> FSMResult:
|
|
203
|
+
n = len(x_cov)
|
|
204
|
+
x_past, z_past = past if past is not None else (x_cov[:0], np.array([]))
|
|
205
|
+
n_past = len(x_past)
|
|
206
|
+
# Past units shape the scaling, the ridge and each group's starting
|
|
207
|
+
# information, but never become candidates.
|
|
208
|
+
x_all = np.vstack([x_past, x_cov])
|
|
209
|
+
if standardize:
|
|
210
|
+
x_all = _standardize(x_all)
|
|
211
|
+
design_all = np.column_stack([np.ones(len(x_all)), x_all]) if intercept else x_all
|
|
212
|
+
if q_initial is None:
|
|
213
|
+
q0 = design_all.T @ design_all
|
|
214
|
+
if np.linalg.matrix_rank(q0) < q0.shape[0]:
|
|
215
|
+
raise ValueError(
|
|
216
|
+
"covariates are collinear, so X'X is singular; drop redundant "
|
|
217
|
+
"columns or pass q_initial (e.g. an identity matrix)"
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
q0 = np.asarray(q_initial, float)
|
|
221
|
+
ridge = eps * q0 / len(x_all)
|
|
222
|
+
x_cov = x_all[n_past:]
|
|
223
|
+
design = design_all[n_past:]
|
|
224
|
+
design_past = design_all[:n_past]
|
|
225
|
+
|
|
226
|
+
groups = list(dict.fromkeys(labels.tolist() + z_past.tolist()))
|
|
227
|
+
gram = {g: design_past[z_past == g].T @ design_past[z_past == g] for g in groups}
|
|
228
|
+
count = {g: int((z_past == g).sum()) for g in groups}
|
|
229
|
+
members: dict[object, list[int]] = {g: [] for g in groups}
|
|
230
|
+
|
|
231
|
+
t_mat = np.zeros((design.shape[1],) * 2)
|
|
232
|
+
if s_function == "aopt":
|
|
233
|
+
if pol_mat is None or w_pol is None:
|
|
234
|
+
raise ValueError("aopt needs pol_mat and w_pol")
|
|
235
|
+
pol = np.atleast_2d(np.asarray(pol_mat, float))
|
|
236
|
+
t_mat = pol.T @ np.diag(np.atleast_1d(w_pol)) @ pol
|
|
237
|
+
pc = (
|
|
238
|
+
_first_pc(x_cov)
|
|
239
|
+
if s_function in {"max_pc", "min_pc", "dopt_pc"}
|
|
240
|
+
else np.empty(0)
|
|
241
|
+
)
|
|
242
|
+
row_mean = x_cov.mean(axis=1)
|
|
243
|
+
|
|
244
|
+
z = np.full(n, -1, dtype=object)
|
|
245
|
+
taken = np.zeros(n, dtype=bool)
|
|
246
|
+
selected = np.empty(n, dtype=int)
|
|
247
|
+
criteria = np.full((n, n), np.nan) if keep_criteria else None
|
|
248
|
+
|
|
249
|
+
for stage, g in enumerate(labels.tolist()):
|
|
250
|
+
avail = np.flatnonzero(~taken)
|
|
251
|
+
current = np.asarray(members[g], dtype=int)
|
|
252
|
+
if s_function in _MATRIX_FUNCTIONS:
|
|
253
|
+
sn = _group_inverse(gram[g], count[g], stage + 1, ridge)
|
|
254
|
+
xa = design[avail]
|
|
255
|
+
quad = np.einsum("ij,jk,ik->i", xa, sn, xa)
|
|
256
|
+
if s_function == "dopt":
|
|
257
|
+
crit = quad
|
|
258
|
+
elif s_function == "negative_dopt":
|
|
259
|
+
crit = -quad
|
|
260
|
+
else:
|
|
261
|
+
num = np.einsum("ij,jk,ik->i", xa, sn @ t_mat @ sn, xa)
|
|
262
|
+
crit = num / (1 + quad)
|
|
263
|
+
elif s_function == "constant":
|
|
264
|
+
crit = np.full(len(avail), 10.0)
|
|
265
|
+
elif s_function == "max_pc":
|
|
266
|
+
crit = pc[avail]
|
|
267
|
+
elif s_function == "min_pc":
|
|
268
|
+
crit = -pc[avail]
|
|
269
|
+
elif s_function == "dopt_pc":
|
|
270
|
+
crit = _spread(pc, current, avail)
|
|
271
|
+
elif s_function == "max_average":
|
|
272
|
+
crit = row_mean[avail]
|
|
273
|
+
elif s_function == "min_average":
|
|
274
|
+
crit = -row_mean[avail]
|
|
275
|
+
elif s_function == "dopt_average":
|
|
276
|
+
crit = _spread(row_mean, current, avail)
|
|
277
|
+
elif s_function == "marginal_var_sum":
|
|
278
|
+
crit = _marginal_var_sum(x_cov, current, avail)
|
|
279
|
+
else:
|
|
280
|
+
raise ValueError(f"unknown selection function {s_function!r}")
|
|
281
|
+
|
|
282
|
+
crit = np.round(crit, _CRITERION_DIGITS)
|
|
283
|
+
if criteria is not None:
|
|
284
|
+
criteria[stage, avail] = crit
|
|
285
|
+
best = avail[crit == crit.max()]
|
|
286
|
+
pick = int(best[0]) if ties == "smallest" else int(rng.choice(best))
|
|
287
|
+
z[pick] = g
|
|
288
|
+
taken[pick] = True
|
|
289
|
+
selected[stage] = pick
|
|
290
|
+
members[g].append(pick)
|
|
291
|
+
gram[g] += np.outer(design[pick], design[pick])
|
|
292
|
+
count[g] += 1
|
|
293
|
+
|
|
294
|
+
assignment = np.array(z.tolist())
|
|
295
|
+
return FSMResult(assignment, selected, criteria)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def fsm_batch(
|
|
299
|
+
x: np.ndarray,
|
|
300
|
+
x_past: np.ndarray,
|
|
301
|
+
z_past: np.ndarray,
|
|
302
|
+
order: SelectionOrder | np.ndarray,
|
|
303
|
+
*,
|
|
304
|
+
s_function: SelectionFunction = "dopt",
|
|
305
|
+
q_initial: np.ndarray | None = None,
|
|
306
|
+
eps: float = 1e-3,
|
|
307
|
+
ties: Literal["random", "smallest"] = "random",
|
|
308
|
+
intercept: bool = True,
|
|
309
|
+
standardize: bool = True,
|
|
310
|
+
pol_mat: np.ndarray | None = None,
|
|
311
|
+
w_pol: np.ndarray | None = None,
|
|
312
|
+
keep_criteria: bool = False,
|
|
313
|
+
rng: np.random.Generator | None = None,
|
|
314
|
+
) -> FSMResult:
|
|
315
|
+
"""Allocate a new batch of units given units already allocated.
|
|
316
|
+
|
|
317
|
+
Each group starts from the information carried by its past members, and
|
|
318
|
+
standardization and the ridge use past and new units together, so a
|
|
319
|
+
sequence of batches behaves like one FSM run over a growing sample.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
x: Covariates of the new units.
|
|
323
|
+
x_past: Covariates of the units already allocated.
|
|
324
|
+
z_past: Group labels of the units already allocated.
|
|
325
|
+
order: Selection order for the new units, one stage per new unit.
|
|
326
|
+
s_function: Selection function; see :func:`fsm`.
|
|
327
|
+
q_initial: See :func:`fsm`.
|
|
328
|
+
eps: See :func:`fsm`.
|
|
329
|
+
ties: See :func:`fsm`.
|
|
330
|
+
intercept: See :func:`fsm`.
|
|
331
|
+
standardize: See :func:`fsm`.
|
|
332
|
+
pol_mat: See :func:`fsm`.
|
|
333
|
+
w_pol: See :func:`fsm`.
|
|
334
|
+
keep_criteria: See :func:`fsm`.
|
|
335
|
+
rng: See :func:`fsm`.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
The allocation of the new units; ``selected`` indexes rows of ``x``.
|
|
339
|
+
|
|
340
|
+
Raises:
|
|
341
|
+
ValueError: If the inputs do not line up.
|
|
342
|
+
"""
|
|
343
|
+
x_new = np.atleast_2d(np.asarray(x, dtype=float).T).T
|
|
344
|
+
past = np.atleast_2d(np.asarray(x_past, dtype=float).T).T
|
|
345
|
+
z_old = np.asarray(z_past)
|
|
346
|
+
labels = np.asarray(order.treat if isinstance(order, SelectionOrder) else order)
|
|
347
|
+
if len(labels) != len(x_new):
|
|
348
|
+
raise ValueError("the selection order needs one stage per new unit")
|
|
349
|
+
if len(z_old) != len(past) or past.shape[1] != x_new.shape[1]:
|
|
350
|
+
raise ValueError("x_past and z_past must describe the same past units")
|
|
351
|
+
return _allocate(
|
|
352
|
+
x_new,
|
|
353
|
+
labels,
|
|
354
|
+
past=(past, z_old),
|
|
355
|
+
s_function=s_function,
|
|
356
|
+
q_initial=q_initial,
|
|
357
|
+
eps=eps,
|
|
358
|
+
ties=ties,
|
|
359
|
+
intercept=intercept,
|
|
360
|
+
standardize=standardize,
|
|
361
|
+
pol_mat=pol_mat,
|
|
362
|
+
w_pol=w_pol,
|
|
363
|
+
keep_criteria=keep_criteria,
|
|
364
|
+
rng=rng or np.random.default_rng(),
|
|
365
|
+
)
|
|
Binary file
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Bundled example data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import pyarrow as pa
|
|
11
|
+
|
|
12
|
+
_LALONDE_COLUMNS = (
|
|
13
|
+
"Age",
|
|
14
|
+
"Education",
|
|
15
|
+
"Black",
|
|
16
|
+
"Hispanic",
|
|
17
|
+
"Married",
|
|
18
|
+
"Nodegree",
|
|
19
|
+
"Re74",
|
|
20
|
+
"Re75",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _require_pyarrow():
|
|
25
|
+
try:
|
|
26
|
+
import pyarrow as pa
|
|
27
|
+
import pyarrow.parquet as pq
|
|
28
|
+
except ImportError as err: # pragma: no cover - exercised without the extra
|
|
29
|
+
raise ImportError(
|
|
30
|
+
'the bundled data needs pyarrow: pip install "finite-selection[data]"'
|
|
31
|
+
) from err
|
|
32
|
+
return pa, pq
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def lalonde_schema() -> pa.Schema:
|
|
36
|
+
"""Return the declared Arrow schema of the bundled Lalonde table."""
|
|
37
|
+
pa, _ = _require_pyarrow()
|
|
38
|
+
return pa.schema([(name, pa.float64()) for name in _LALONDE_COLUMNS])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def lalonde() -> pd.DataFrame:
|
|
42
|
+
"""Dehejia and Wahba's subset of the National Supported Work data.
|
|
43
|
+
|
|
44
|
+
445 men (185 trained, 260 controls) with eight baseline covariates: age,
|
|
45
|
+
years of education, indicators for Black, Hispanic, married and no high
|
|
46
|
+
school degree, and earnings in 1974 and 1975. Rebuilt from
|
|
47
|
+
https://users.nber.org/~rdehejia/data/nsw_dw.dta by
|
|
48
|
+
``scripts/build_lalonde.py``; rows follow the order of the R package FSM.
|
|
49
|
+
Needs the ``data`` extra.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
One row per unit, one float column per covariate.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ValueError: If the packaged file does not match its declared schema.
|
|
56
|
+
"""
|
|
57
|
+
_, pq = _require_pyarrow()
|
|
58
|
+
with files("finite_selection").joinpath("data/lalonde.parquet").open("rb") as fh:
|
|
59
|
+
table = pq.read_table(fh)
|
|
60
|
+
if not table.schema.equals(lalonde_schema()):
|
|
61
|
+
raise ValueError(f"lalonde.parquet has schema {table.schema}")
|
|
62
|
+
return table.to_pandas()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Covariate transformations for balancing beyond the means."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from itertools import combinations
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def make_sq_inter(
|
|
11
|
+
data: pd.DataFrame,
|
|
12
|
+
*,
|
|
13
|
+
is_square: bool = True,
|
|
14
|
+
is_inter: bool = True,
|
|
15
|
+
keep_marginal: bool = True,
|
|
16
|
+
) -> pd.DataFrame:
|
|
17
|
+
"""Add squares and pairwise products of the columns of ``data``.
|
|
18
|
+
|
|
19
|
+
Feeding these to :func:`~finite_selection.fsm` balances second moments
|
|
20
|
+
and interactions, which balancing the raw columns does not guarantee.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
data: Covariates.
|
|
24
|
+
is_square: Add ``col^2`` for each column.
|
|
25
|
+
is_inter: Add ``a.b`` for each pair of columns.
|
|
26
|
+
keep_marginal: Keep the original columns first.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Marginal columns, then interactions, then squares, each in input
|
|
30
|
+
column order.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ValueError: If every option is off, leaving nothing to return.
|
|
34
|
+
"""
|
|
35
|
+
if not (is_square or is_inter or keep_marginal):
|
|
36
|
+
raise ValueError("nothing to return: enable at least one option")
|
|
37
|
+
parts = []
|
|
38
|
+
if keep_marginal:
|
|
39
|
+
parts.append(data)
|
|
40
|
+
if is_inter:
|
|
41
|
+
parts.append(
|
|
42
|
+
pd.DataFrame(
|
|
43
|
+
{
|
|
44
|
+
f"{a}.{b}": data[a] * data[b]
|
|
45
|
+
for a, b in combinations(data.columns, 2)
|
|
46
|
+
},
|
|
47
|
+
index=data.index,
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
if is_square:
|
|
51
|
+
parts.append(
|
|
52
|
+
pd.DataFrame(
|
|
53
|
+
{f"{a}^2": data[a] ** 2 for a in data.columns}, index=data.index
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
return pd.concat(parts, axis=1)
|