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.
@@ -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
+ ]
@@ -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)