weightpipe 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.
weightpipe/__init__.py ADDED
@@ -0,0 +1,83 @@
1
+ """weightpipe: declarative survey weighting with recipe-aware replicates."""
2
+
3
+ from weightpipe._logging import set_log_level, setup_logging
4
+ from weightpipe.design import Design
5
+ from weightpipe.diagnostics import BalanceReport, balance, design_effect, ess, margins
6
+ from weightpipe.estimate import Estimation, estimate, estimate_glm, point_estimate
7
+ from weightpipe.frame import WeightFrame
8
+ from weightpipe.methods import design_matrix, population_totals
9
+ from weightpipe.pipeline import WeightPipe
10
+ from weightpipe.planning import (
11
+ allocate_strata,
12
+ allocation_table,
13
+ margin_of_error,
14
+ sample_size,
15
+ stratified_margin_of_error,
16
+ )
17
+ from weightpipe.recipe import Recipe
18
+ from weightpipe.replicates import (
19
+ BootstrapResult,
20
+ JackknifeResult,
21
+ boot_mean,
22
+ boot_median,
23
+ boot_proportion,
24
+ boot_ratio,
25
+ boot_total,
26
+ bootstrap_estimate,
27
+ bootstrap_weights,
28
+ jack_mean,
29
+ jack_median,
30
+ jack_proportion,
31
+ jack_ratio,
32
+ jack_total,
33
+ jackknife_estimate,
34
+ jackknife_weights,
35
+ )
36
+ from weightpipe.result import WeightResult, collect_weights, weight_factors
37
+
38
+ __all__ = [
39
+ "BalanceReport",
40
+ "BootstrapResult",
41
+ "Design",
42
+ "Estimation",
43
+ "JackknifeResult",
44
+ "Recipe",
45
+ "WeightFrame",
46
+ "WeightPipe",
47
+ "WeightResult",
48
+ "__version__",
49
+ "allocate_strata",
50
+ "allocation_table",
51
+ "balance",
52
+ "boot_mean",
53
+ "boot_median",
54
+ "boot_proportion",
55
+ "boot_ratio",
56
+ "boot_total",
57
+ "bootstrap_estimate",
58
+ "bootstrap_weights",
59
+ "collect_weights",
60
+ "design_effect",
61
+ "design_matrix",
62
+ "ess",
63
+ "estimate",
64
+ "estimate_glm",
65
+ "jack_mean",
66
+ "jack_median",
67
+ "jack_proportion",
68
+ "jack_ratio",
69
+ "jack_total",
70
+ "jackknife_estimate",
71
+ "jackknife_weights",
72
+ "margin_of_error",
73
+ "margins",
74
+ "point_estimate",
75
+ "population_totals",
76
+ "sample_size",
77
+ "set_log_level",
78
+ "setup_logging",
79
+ "stratified_margin_of_error",
80
+ "weight_factors",
81
+ ]
82
+
83
+ __version__ = "0.1.0"
weightpipe/_logging.py ADDED
@@ -0,0 +1,50 @@
1
+ """Package logging: quiet by default, opt-in readable output."""
2
+
3
+ import logging
4
+ import sys
5
+ from typing import TextIO
6
+
7
+ ROOT_LOGGER_NAME = "weightpipe"
8
+ LOG_FORMAT = "%(asctime)s %(message)s"
9
+ DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
10
+
11
+ _HANDLER_FLAG = "_weightpipe_handler"
12
+
13
+ _root = logging.getLogger(ROOT_LOGGER_NAME)
14
+ # Libraries should not emit anything unless the application asks for it.
15
+ _root.addHandler(logging.NullHandler())
16
+
17
+
18
+ def get_logger(name: str) -> logging.Logger:
19
+ """Logger for a module inside the package (``weightpipe.<module>``)."""
20
+ return logging.getLogger(name)
21
+
22
+
23
+ def setup_logging(
24
+ level: int | str = "INFO",
25
+ *,
26
+ stream: TextIO | None = None,
27
+ fmt: str = LOG_FORMAT,
28
+ datefmt: str = DATE_FORMAT,
29
+ ) -> logging.Logger:
30
+ """Send weightpipe messages to ``stream`` with a compact format.
31
+
32
+ Calling this repeatedly replaces the handler instead of stacking new ones,
33
+ and it leaves the application's root logger configuration untouched.
34
+ """
35
+ for handler in [h for h in _root.handlers if getattr(h, _HANDLER_FLAG, False)]:
36
+ _root.removeHandler(handler)
37
+
38
+ handler = logging.StreamHandler(stream if stream is not None else sys.stderr)
39
+ handler.setFormatter(logging.Formatter(fmt=fmt, datefmt=datefmt))
40
+ setattr(handler, _HANDLER_FLAG, True)
41
+
42
+ _root.addHandler(handler)
43
+ _root.setLevel(level)
44
+ _root.propagate = False
45
+ return _root
46
+
47
+
48
+ def set_log_level(level: int | str) -> None:
49
+ """Change the verbosity of weightpipe messages."""
50
+ _root.setLevel(level)
weightpipe/design.py ADDED
@@ -0,0 +1,233 @@
1
+ """Sampling design: base weights and variance structure (strata / PSU)."""
2
+
3
+ from collections.abc import Sequence
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ from weightpipe._logging import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ def _as_col_list(value: str | Sequence[str] | None) -> list[str] | None:
16
+ if value is None:
17
+ return None
18
+ if isinstance(value, str):
19
+ return [value]
20
+ cols = [str(c) for c in value]
21
+ if not cols:
22
+ raise ValueError("stage column list must be non-empty")
23
+ return cols
24
+
25
+
26
+ def _infer_kind(
27
+ *,
28
+ N: Any,
29
+ N_h: Any,
30
+ weight: str | None,
31
+ probabilities: list[str] | None,
32
+ stage_weights: list[str] | None,
33
+ strata: str | None,
34
+ psu: str | None,
35
+ ) -> str:
36
+ if N is not None:
37
+ return "srs"
38
+ if N_h is not None:
39
+ return "stratified"
40
+ if probabilities is not None or stage_weights is not None:
41
+ return "stratified_multistage" if strata is not None else "multistage"
42
+ if psu is not None:
43
+ return "stratified_cluster" if strata is not None else "cluster"
44
+ return "custom"
45
+
46
+
47
+ def _product_weights(
48
+ frame: pd.DataFrame,
49
+ columns: list[str],
50
+ *,
51
+ as_probabilities: bool,
52
+ ) -> np.ndarray:
53
+ missing = [c for c in columns if c not in frame.columns]
54
+ if missing:
55
+ raise KeyError(f"stage columns not found: {missing}")
56
+ mat = frame.loc[:, columns].to_numpy(dtype=float)
57
+ if not np.isfinite(mat).all():
58
+ raise ValueError("stage columns must be finite")
59
+ if as_probabilities:
60
+ if np.any((mat <= 0) | (mat > 1)):
61
+ raise ValueError("inclusion probabilities must be in (0, 1]")
62
+ with np.errstate(divide="raise", invalid="raise"):
63
+ return 1.0 / np.prod(mat, axis=1)
64
+ if np.any(mat < 0):
65
+ raise ValueError("stage weights must be non-negative")
66
+ return np.prod(mat, axis=1)
67
+
68
+
69
+ @dataclass(frozen=True, init=False)
70
+ class Design:
71
+ """Sampling design metadata and base weights.
72
+
73
+ Pass the inputs that define the design; ``kind`` is inferred:
74
+
75
+ - *(none)* → unit weights ``1`` (with a log message)
76
+ - ``N=...`` → SRS, weights ``N/n``
77
+ - ``strata=...``, ``N_h=...`` → stratified SRS, weights ``N_h/n_h``
78
+ - ``weight=...``, ``psu=...`` → cluster (add ``strata=`` if stratified)
79
+ - ``probabilities=[...]``, ``psu=...`` → multi-stage, ``w = 1 / ∏ π_k``
80
+ - ``stage_weights=[...]``, ``psu=...`` → multi-stage, ``w = ∏ w_k``
81
+ - ``weight=...`` only → use existing weights
82
+
83
+ For multi-stage / cluster designs, ``psu`` is the ultimate cluster used for
84
+ bootstrap/jackknife variance. Stage details are folded into the weight.
85
+
86
+ Base weights live in ``data[weight]``.
87
+ """
88
+
89
+ data: pd.DataFrame
90
+ weight: str
91
+ strata: str | None = None
92
+ psu: str | None = None
93
+ kind: str = "custom"
94
+ meta: dict[str, Any] = field(default_factory=dict)
95
+
96
+ def __init__(
97
+ self,
98
+ data: pd.DataFrame,
99
+ *,
100
+ weight: str | None = None,
101
+ strata: str | None = None,
102
+ psu: str | None = None,
103
+ N: float | int | None = None,
104
+ N_h: dict[Any, float] | pd.Series | None = None,
105
+ probabilities: str | Sequence[str] | None = None,
106
+ stage_weights: str | Sequence[str] | None = None,
107
+ copy: bool = True,
108
+ ) -> None:
109
+ prob_cols = _as_col_list(probabilities)
110
+ stage_cols = _as_col_list(stage_weights)
111
+ sources = (N, N_h, weight, prob_cols, stage_cols)
112
+ n_sources = sum(x is not None for x in sources)
113
+ if n_sources > 1:
114
+ raise ValueError("provide at most one of N=, N_h=, weight=, probabilities=, or stage_weights=")
115
+ if N is not None and (strata is not None or psu is not None):
116
+ raise ValueError("N= (SRS) cannot be combined with strata= or psu=")
117
+ if N_h is not None and strata is None:
118
+ raise ValueError("N_h= requires strata=")
119
+ if N_h is not None and psu is not None:
120
+ raise ValueError("N_h= stratified SRS cannot be combined with psu=")
121
+ if (prob_cols is not None or stage_cols is not None) and psu is None:
122
+ raise ValueError("multi-stage designs require psu= (ultimate cluster for variance)")
123
+
124
+ frame = data.copy() if copy else data
125
+ meta: dict[str, Any] = {}
126
+ weight_col: str
127
+ kind = _infer_kind(
128
+ N=N,
129
+ N_h=N_h,
130
+ weight=weight,
131
+ probabilities=prob_cols,
132
+ stage_weights=stage_cols,
133
+ strata=strata,
134
+ psu=psu,
135
+ )
136
+
137
+ if n_sources == 0:
138
+ weight_col = "base_weight"
139
+ if len(frame) < 1:
140
+ raise ValueError("data must have at least one row")
141
+ if weight_col in frame.columns:
142
+ raise ValueError(f"column {weight_col!r} already exists")
143
+ frame = frame.assign(**{weight_col: 1.0})
144
+ meta = {"unit_weights": True, "note": "no design weight provided; using 1.0"}
145
+ logger.info("No design weight provided; using base_weight=1.0 for all rows")
146
+ elif N is not None:
147
+ weight_col = "base_weight"
148
+ n = len(frame)
149
+ if n < 1:
150
+ raise ValueError("data must have at least one row")
151
+ if float(N) <= 0:
152
+ raise ValueError("N must be positive")
153
+ if weight_col in frame.columns:
154
+ raise ValueError(f"column {weight_col!r} already exists")
155
+ frame = frame.assign(**{weight_col: float(N) / n})
156
+ meta = {"N": float(N), "n": n}
157
+ strata = None
158
+ psu = None
159
+ elif N_h is not None:
160
+ assert strata is not None
161
+ weight_col = "base_weight"
162
+ if strata not in frame.columns:
163
+ raise KeyError(f"strata column not found: {strata}")
164
+ if weight_col in frame.columns:
165
+ raise ValueError(f"column {weight_col!r} already exists")
166
+ n_h = frame.groupby(strata, observed=True).size()
167
+ pop = {str(k): float(v) for k, v in dict(N_h).items()}
168
+ weights = np.empty(len(frame), dtype=float)
169
+ meta_nh: dict[str, dict[str, float]] = {}
170
+ labels = frame[strata].astype(str)
171
+ for label, n in n_h.items():
172
+ key = str(label)
173
+ if key not in pop:
174
+ raise KeyError(f"N_h missing stratum {label!r}")
175
+ Nh = pop[key]
176
+ if n < 1 or Nh <= 0:
177
+ raise ValueError(f"invalid N_h/n_h for stratum {label!r}: N={Nh}, n={n}")
178
+ w = Nh / float(n)
179
+ weights[labels.to_numpy() == key] = w
180
+ meta_nh[key] = {"N": Nh, "n": float(n), "weight": w}
181
+ frame = frame.assign(**{weight_col: weights})
182
+ meta = {"N_h": meta_nh}
183
+ psu = None
184
+ elif prob_cols is not None or stage_cols is not None:
185
+ weight_col = "base_weight"
186
+ if weight_col in frame.columns:
187
+ raise ValueError(f"column {weight_col!r} already exists")
188
+ if prob_cols is not None:
189
+ weights = _product_weights(frame, prob_cols, as_probabilities=True)
190
+ meta = {
191
+ "stages": len(prob_cols),
192
+ "probabilities": list(prob_cols),
193
+ "formula": "1 / prod(probabilities)",
194
+ }
195
+ else:
196
+ assert stage_cols is not None
197
+ weights = _product_weights(frame, stage_cols, as_probabilities=False)
198
+ meta = {
199
+ "stages": len(stage_cols),
200
+ "stage_weights": list(stage_cols),
201
+ "formula": "prod(stage_weights)",
202
+ }
203
+ frame = frame.assign(**{weight_col: weights})
204
+ else:
205
+ assert weight is not None
206
+ weight_col = weight
207
+ if weight_col not in frame.columns:
208
+ raise KeyError(f"weight column not found: {weight_col}")
209
+
210
+ if strata is not None and strata not in frame.columns:
211
+ raise KeyError(f"strata column not found: {strata}")
212
+ if psu is not None and psu not in frame.columns:
213
+ raise KeyError(f"psu column not found: {psu}")
214
+ w = frame[weight_col]
215
+ if (w < 0).any():
216
+ raise ValueError("design weights must be non-negative")
217
+
218
+ object.__setattr__(self, "data", frame)
219
+ object.__setattr__(self, "weight", weight_col)
220
+ object.__setattr__(self, "strata", strata)
221
+ object.__setattr__(self, "psu", psu)
222
+ object.__setattr__(self, "kind", kind)
223
+ object.__setattr__(self, "meta", meta)
224
+
225
+ def to_dict(self) -> dict[str, Any]:
226
+ return {
227
+ "weight": self.weight,
228
+ "strata": self.strata,
229
+ "psu": self.psu,
230
+ "kind": self.kind,
231
+ "meta": dict(self.meta),
232
+ "n": len(self.data),
233
+ }
@@ -0,0 +1,52 @@
1
+ """Weight / design-effect / balance diagnostics."""
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+
6
+ from weightpipe.diagnostics.balance import BalanceReport, balance
7
+ from weightpipe.diagnostics.margins import (
8
+ attach_margin_table,
9
+ margin_table_from_targets,
10
+ margins,
11
+ weighted_category_margins,
12
+ )
13
+ from weightpipe.result import WeightResult
14
+
15
+ __all__ = [
16
+ "BalanceReport",
17
+ "attach_margin_table",
18
+ "balance",
19
+ "design_effect",
20
+ "ess",
21
+ "margin_table_from_targets",
22
+ "margins",
23
+ "weighted_category_margins",
24
+ ]
25
+
26
+
27
+ def design_effect(weights: pd.Series | WeightResult) -> float:
28
+ """Kish design effect from unequal weighting: 1 + CV(w)^2 = n * sum(w^2) / sum(w)^2."""
29
+ w = weights.weights if isinstance(weights, WeightResult) else weights
30
+ w = w.astype(float)
31
+ active = w[w > 0]
32
+ if active.empty:
33
+ return float("nan")
34
+ n = float(len(active))
35
+ s = float(active.sum())
36
+ if s <= 0:
37
+ return float("nan")
38
+ return float(n * float(np.sum(np.square(active.to_numpy()))) / (s * s))
39
+
40
+
41
+ def ess(weights: pd.Series | WeightResult) -> float:
42
+ """Effective sample size under Kish: (sum w)^2 / sum(w^2)."""
43
+ w = weights.weights if isinstance(weights, WeightResult) else weights
44
+ w = w.astype(float)
45
+ active = w[w > 0]
46
+ if active.empty:
47
+ return float("nan")
48
+ s = float(active.sum())
49
+ ss = float(np.sum(np.square(active.to_numpy())))
50
+ if ss <= 0:
51
+ return float("nan")
52
+ return float((s * s) / ss)