Skip to content

Commit b70b8e1

Browse files
Performance oriented refactoring
1 parent aede4ad commit b70b8e1

39 files changed

Lines changed: 893 additions & 858 deletions

‎packages/svy/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "svy"
3-
version = "0.15.1"
3+
version = "0.16.0"
44
description = "End-to-end surveys: design, sample, analyze, report."
55
readme = "README.md"
66
license = { file = "LICENSE" }

‎packages/svy/src/svy/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@
126126
# Ensure no “No handlers could be found” warnings in user apps
127127
logging.getLogger(__name__).addHandler(logging.NullHandler())
128128

129-
__version__ = "0.15.1"
129+
__version__ = "0.16.0"
130130

131131
__all__ = [
132132
# --- Core Classes ---

‎packages/svy/src/svy/categorical/base.py‎

Lines changed: 58 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -230,16 +230,12 @@ def tabulate(
230230
psu_col = f"psu{_INTERNAL_CONCAT_SUFFIX}" if design.psu is not None else None
231231
ssu_col = f"ssu{_INTERNAL_CONCAT_SUFFIX}" if design.ssu is not None else None
232232

233-
# Cast columns to correct types
234-
concat_data = concat_data.with_columns(pl.col(rowvar).cast(pl.String))
235-
if colvar:
236-
concat_data = concat_data.with_columns(pl.col(colvar).cast(pl.String))
237-
if strata_col and strata_col in concat_data.columns:
238-
concat_data = concat_data.with_columns(pl.col(strata_col).cast(pl.String))
239-
if psu_col and psu_col in concat_data.columns:
240-
concat_data = concat_data.with_columns(pl.col(psu_col).cast(pl.String))
241-
if ssu_col and ssu_col in concat_data.columns:
242-
concat_data = concat_data.with_columns(pl.col(ssu_col).cast(pl.String))
233+
# Cast all design columns to String in a single with_columns call
234+
_cast_cols = [rowvar] + ([colvar] if colvar else [])
235+
for _c in [strata_col, psu_col, ssu_col]:
236+
if _c and _c in concat_data.columns:
237+
_cast_cols.append(_c)
238+
concat_data = concat_data.with_columns([pl.col(c).cast(pl.String) for c in _cast_cols])
243239

244240
# Determine whether to compute totals (weights not normalized)
245241
compute_totals = abs(float(wgt_arr.sum()) - 1.0) > 1e-6
@@ -256,12 +252,11 @@ def tabulate(
256252
compute_totals=compute_totals,
257253
)
258254

259-
# Unpack cells DataFrame into CellEst objects
255+
# Unpack cells DataFrame into CellEst objects — vectorized CI computation
260256
df_val = int(cells_df["df"][0]) if cells_df.height > 0 else 1
261257
t_crit = float(t_dist.ppf(1 - alpha / 2, df_val))
262258
z_crit = float(norm_dist.ppf(1 - alpha / 2))
263259

264-
import math
265260
import re
266261

267262
_NUMERIC_RE = re.compile(r"^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$")
@@ -277,53 +272,64 @@ def _norm_label(v: str) -> str:
277272
pass
278273
return s
279274

280-
cell_rows = []
281-
for row in cells_df.iter_rows(named=True):
282-
est = row["est"]
283-
se = row["se"]
275+
# Extract all columns as numpy arrays — one pass, no iter_rows
276+
est_arr = cells_df["est"].to_numpy()
277+
se_arr = cells_df["se"].to_numpy()
278+
cv_arr = cells_df["cv"].to_numpy()
279+
rowvar_arr = cells_df["rowvar"].to_list()
280+
colvar_arr = cells_df["colvar"].to_list()
284281

285-
if compute_totals:
286-
lci = est - z_crit * se
287-
uci = est + z_crit * se
288-
else:
289-
if est <= 0 or est >= 1 or se <= 0:
290-
lci, uci = est, est
291-
else:
292-
scale = se / (est * (1.0 - est))
293-
logit = math.log(est / (1 - est))
294-
lci = 1.0 / (1.0 + math.exp(-(logit - t_crit * scale)))
295-
uci = 1.0 / (1.0 + math.exp(-(logit + t_crit * scale)))
296-
297-
cell_rows.append(
298-
CellEst(
299-
rowvar=_norm_label(row["rowvar"]),
300-
colvar=_norm_label(row["colvar"]),
301-
est=est,
302-
se=se,
303-
cv=row["cv"],
304-
lci=lci,
305-
uci=uci,
306-
)
282+
if compute_totals:
283+
lci_arr = est_arr - z_crit * se_arr
284+
uci_arr = est_arr + z_crit * se_arr
285+
else:
286+
# Logit CI — fully vectorized with masked ops
287+
valid = (est_arr > 0) & (est_arr < 1) & (se_arr > 0)
288+
# Safe denominators for masked positions
289+
_p = np.where(valid, est_arr, 0.5)
290+
scale = np.where(valid, se_arr / (_p * (1.0 - _p)), 0.0)
291+
logit = np.where(valid, np.log(_p / (1.0 - _p)), 0.0)
292+
lci_arr = np.where(
293+
valid,
294+
1.0 / (1.0 + np.exp(-(logit - t_crit * scale))),
295+
est_arr,
296+
)
297+
uci_arr = np.where(
298+
valid,
299+
1.0 / (1.0 + np.exp(-(logit + t_crit * scale))),
300+
est_arr,
301+
)
302+
303+
cell_rows = [
304+
CellEst(
305+
rowvar=_norm_label(rowvar_arr[i]),
306+
colvar=_norm_label(colvar_arr[i]),
307+
est=float(est_arr[i]),
308+
se=float(se_arr[i]),
309+
cv=float(cv_arr[i]),
310+
lci=float(lci_arr[i]),
311+
uci=float(uci_arr[i]),
307312
)
313+
for i in range(len(est_arr))
314+
]
308315

309316
# Unpack stats DataFrame into TableStats (two-way only)
310317
tbl_stats = None
311318
if colvar is not None and stats_df.height > 0:
312-
stats_map = {row["stat"]: row for row in stats_df.iter_rows(named=True)}
313-
cs = stats_map.get("chisq", {})
314-
fs = stats_map.get("f", {})
319+
cs = stats_df.filter(pl.col("stat") == "chisq").row(0, named=True)
320+
fs = stats_df.filter(pl.col("stat") == "f").row(0, named=True)
315321

316322
tbl_stats = TableStats(
317323
chisq=ChiSquare(
318-
df=int(cs.get("df", 0)),
319-
value=cs.get("value", 0.0),
320-
p_value=cs.get("p_value", 1.0),
324+
df=int(cs["df"]),
325+
value=cs["value"],
326+
p_value=cs["p_value"],
321327
),
322328
f=FDist(
323-
df_num=fs.get("df", 0.0),
324-
df_den=fs.get("df2", 0.0),
325-
value=fs.get("value", 0.0),
326-
p_value=fs.get("p_value", 1.0),
329+
df_num=fs["df"],
330+
df_den=fs["df2"],
331+
value=fs["value"],
332+
p_value=fs["p_value"],
327333
),
328334
)
329335

@@ -844,16 +850,10 @@ def _compute_midranks(y: np.ndarray, w: np.ndarray) -> tuple[np.ndarray, float]:
844850
cumw = np.cumsum(w_sorted)
845851
midrank_sorted = cumw - w_sorted / 2.0
846852

847-
# Average over ties
848-
rankhat_sorted = np.empty(n, dtype=np.float64)
849-
i = 0
850-
while i < n:
851-
j = i + 1
852-
while j < n and y_sorted[j] == y_sorted[i]:
853-
j += 1
854-
avg_midrank = np.mean(midrank_sorted[i:j])
855-
rankhat_sorted[i:j] = avg_midrank
856-
i = j
853+
# Average over ties — vectorized with np.unique + np.bincount
854+
_, inverse, counts = np.unique(y_sorted, return_inverse=True, return_counts=True)
855+
group_sums = np.bincount(inverse, weights=midrank_sorted, minlength=len(counts))
856+
rankhat_sorted = (group_sums / counts)[inverse]
857857

858858
rankhat = np.empty(n, dtype=np.float64)
859859
rankhat[ii] = rankhat_sorted

‎packages/svy/src/svy/categorical/table.py‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -740,21 +740,17 @@ def crosstab(
740740
if resolve_labels and self._metadata is not None:
741741
row_resolved = self._metadata.resolve_labels(self.rowvar)
742742
if row_resolved.has_value_labels:
743+
_row_label_map = {str(k): str(v) for k, v in row_resolved.value_labels.items()}
743744
df = df.with_columns(
744-
pl.col("rowvar").map_elements(
745-
lambda v: row_resolved.display(v),
746-
return_dtype=pl.Utf8,
747-
)
745+
pl.col("rowvar").replace_strict(_row_label_map, default=pl.col("rowvar"))
748746
)
749747

750748
if self.colvar is not None:
751749
col_resolved = self._metadata.resolve_labels(self.colvar)
752750
if col_resolved.has_value_labels:
751+
_col_label_map = {str(k): str(v) for k, v in col_resolved.value_labels.items()}
753752
df = df.with_columns(
754-
pl.col("colvar").map_elements(
755-
lambda v: col_resolved.display(v),
756-
return_dtype=pl.Utf8,
757-
)
753+
pl.col("colvar").replace_strict(_col_label_map, default=pl.col("colvar"))
758754
)
759755

760756
row_levels = list(

‎packages/svy/src/svy/core/data_prep.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from dataclasses import dataclass, field
2828
from typing import TYPE_CHECKING, Mapping, Sequence, cast
2929

30+
3031
import polars as pl
3132

3233
from svy.core.constants import _INTERNAL_CONCAT_SUFFIX
@@ -39,6 +40,10 @@
3940

4041
log = logging.getLogger(__name__)
4142

43+
# Cache for design.specified_fields() keyed on id(design).
44+
# Stores (design_obj, fields) so we can detect if a new object reuses the same id.
45+
_design_fields_cache: dict[int, tuple] = {}
46+
4247

4348
# ═══════════════════════════════════════════════════════════════════════
4449
# Return type
@@ -199,7 +204,14 @@ def prepare_data(
199204
if extra_cols:
200205
needed.extend(extra_cols)
201206
needed.extend(extract_where_cols(where))
202-
needed.extend(design.specified_fields())
207+
key = id(design)
208+
cached = _design_fields_cache.get(key)
209+
if cached is None or cached[0] is not design:
210+
fields = design.specified_fields(data_columns=local_data.columns)
211+
_design_fields_cache[key] = (design, fields)
212+
else:
213+
fields = cached[1]
214+
needed.extend(fields)
203215
# Include singleton variance/exclude columns if present in data
204216
_sr_pre = getattr(sample, "_singleton_result", None)
205217
_sc_pre = _sr_pre.config if _sr_pre else None

‎packages/svy/src/svy/core/sample.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,11 @@ def _concatenate_cols(
485485
for group_name, cols in groups.items():
486486
if not cols:
487487
continue
488+
out_col = f"{group_name}{rename_suffix}"
489+
# Skip if the concatenated column already exists (e.g. stratum/psu/ssu
490+
# built at Sample.__init__ and still present in the data).
491+
if out_col in names:
492+
continue
488493
missing = [c for c in cols if c not in names]
489494
if missing:
490495
raise KeyError(
@@ -494,7 +499,7 @@ def _concatenate_cols(
494499
expr = pl.concat_str(parts, separator=sep)
495500
if categorical:
496501
expr = expr.cast(pl.Categorical)
497-
key_exprs.append(expr.alias(f"{group_name}{rename_suffix}"))
502+
key_exprs.append(expr.alias(out_col))
498503
used_inputs.update(cols)
499504

500505
out = data.with_columns(key_exprs)
@@ -621,7 +626,6 @@ def _is_numeric_dtype(dt: pl.DataType) -> bool:
621626
return dt in INTEGER_DTYPES or dt in FLOAT_DTYPES
622627

623628
# 1. Validate explicit design fields (stratum, psu, wgt, etc.)
624-
# FIX: Pass data_columns to enable auto-detection of replicate weight padding
625629
needed_cols = design.specified_fields(
626630
ignore_cols=("wr",),
627631
data_columns=data.columns,

0 commit comments

Comments
 (0)