Release Notes
All notable changes to svy, the Python package for design-based analysis of complex survey data — means, totals, ratios, proportions, regression, weighting, and sample selection — are recorded here. Releases follow Semantic Versioning; the layout follows Keep a Changelog.
Companion packages track their own changes: svy-io (SAS/SPSS/Stata I/O) and svy-rs (internal Rust extension).
Unreleased
0.25.0 — 2026-08-26
Added
Replicate weights carry the units they were built from.
stratumandpsuon every variant name the columns the replicates were drawn over — a separate question fromDesign.stratum/Design.psu, which describe the analysis design and are what Taylor linearizes over. Producers collapse strata and suppress PSUs for disclosure, publishing a distinct pair (VARSTRAT/VARUNITand its many spellings) alongside — or instead of — the design variables:JackknifeWgts(prefix="rw", n_reps=8, kind="jkn", stratum="VARSTRAT", psu="VARUNIT")They appear in
reprandprint(design), are validated as columns atSampleconstruction, and are rewritten bywrangling.rename_columnsand protected byremove_columnsexactly like design columns. Generators record whatever they used, so a generated design states its provenance rather than leaving a later reader to assume it matches the Design. The Poisson bootstrap records neither, drawing independent per-unit factors and having no units by construction.They take the same shapes
Design’s do —strfor the single collapsed identifier producers usually ship, or a tuple when the unit is several columns together — and the same spellings mean the same thing on both objects, including("region",)staying a one-tuple rather than being unwrapped. Multi-column units are grouped on directly rather than through the internal concatenated columnDesignbuilds, so nothing implementation-specific reaches anything reading provenance back.create_brr_wgtsandcreate_jk_wgts(paired=True)pair PSUs into variance strata themselves.stratum_namenames the created column (the housewgt_nameconvention),order_bypairs adjacent PSUs in that order — what a systematically-sampled frame wants — andshufflepairs at random.stratum/psuon all four generators build from columns the Design does not name, without mutating it.Poisson bootstrap replicate weights (#131).
sample.weighting.create_bs_wgts(kind="poisson")generates Beaumont–Patak generalized bootstrap weights, which need only a weight column. The defaultkind="rao-wu"is the stratified Rao–Wu–Yue rescaling bootstrap and still requirespsuon the design — the guard is deliberately not shared, since the Poisson bootstrap exists precisely for files that have no PSU. Both kinds use the same1/Rper-replicate coefficient; they differ in how the replicates are drawn, not in how the variance is scaled.Beaumont, J.-F. and Patak, Z. (2012). On the generalized bootstrap for sample surveys with special attention to Poisson sampling. International Statistical Review, 80(1), 127–148.
scale: the coefficients a producer publishes (#7). Replicate weights often ship with a documented per-replicate variance coefficient that is not the method’s default. CETIC publishes the ICT Households microdata with0.0065365334145709at R = 200, which bakes in a finite-population correction. Declaring it is now a parameter rather than an arithmetic exercise:svy.RepWeights(method="bootstrap", prefix="REP", n_reps=200, scale=0.0065365334145709)A scalar is broadcast to every replicate; a sequence must match
n_repsand is checked at construction. It replaces, rather than multiplies, the method default:V = Σ_r scale_r · (θ_r − θ̄)². svy’sscaleis Rsvrepdesign’sscale × rscalesfolded into one field, sosvrepdesign(type = "bootstrap", scale = c, rscales = rep(1, R))isscale=chere, with no conversion factor.examples/ict_households.pydrops thesqrt(scale * R)workaround it used to demonstrate.Jackknife designs say which family they are.
JackknifeWgts.kindtakes R’ssvrepdesign(type=)names —"jk1"(unstratified delete-one-PSU,(R−1)/R),"jkn"(stratified,(n_h−1)/n_hper replicate),"jk2"(paired, one replicate per stratum,1.0)."paired"is accepted as an alias for"jk2": it describes the design, not the replication scheme, since a JKn on a paired design is still JKn.The default is
None— unspecified.Noneand"jk1"produce the same number but are different statements, and svy claims only what it knows or was told. A producer withholding design variables is not evidence that the weights are unstratified.Sample.rep_coefsshows which coefficient was applied to which replicate, keyed by the actual column name:>>> sample.rep_coefs {'jk1': 0.6667, 'jk2': 0.6667, 'jk3': 0.6667, 'jk4': 0.5, ...}coefficients()on the variant remains the machine path — a list ofn_repsfloats in replicate order, the shape the kernel takes — but a bare list makes the assignment something a reader has to trust rather than check, and for an unbalanced JKn the assignment is the answer: the same coefficients against a different order give a different standard error.It lives on
Samplerather than onrep_wgtsbecause only the frame resolves the column names: a design declaringprefix="REP"against a file shippingREP001..REP200knows the replicate count but not the padding, so the struct alone would key this onREP1and be confidently wrong. Empty when the design carries no replicate weights, and it raises whatevercoefficients()raises rather than reporting a number it does not have.rep_wgts.coef_sourcenames the provenance alongside it —"scale"(the user asserted them),"derived"(svy computed them and cannot redo it) or"default"(the method’s standard value). A varying vector also prints its distinct values with counts now,0.667 x3, 0.5 x4, rather than first-and-last — which two numbers land at the ends is an artefact of the producer’s replicate order, while the counts are the design.kindis optional once the units are named.jk1/jkn/jk2is expert vocabulary; naming the columns the replicates were built from is not, and requiring both asks for the same fact twice in the harder of the two languages. Withstratumandpsudeclared, svy reads the scheme off the counts — one replicate per PSU across several strata is JKn, per PSU in a single stratum is JK1, one per stratum is JK2 — and records it, at INFO level so the inference is auditable:JackknifeWgts(prefix="jk", n_reps=8, stratum=("region", "urban"), psu="psu") # -> kind='jkn', rep_coefs=0.5 (derived)This used to refuse, on the grounds that deriving a kind would turn “the producer withheld the design” into “these weights are unstratified”. That was right while the counts came from
Design.stratum, which says nothing about how the replicates were drawn; the units declared on the weights are exactly that statement.A
kindthat the units contradict is now an error rather than a warning, but only whenn_repslands exactly on another scheme’s count — then the label is simply wrong and svy can say which it should be, and warning would ship a coefficient wrong by a known factor. Declaringjk2against 8 replicates and 8 PSUs raises and namesjk1/jkn. A count matching no scheme still only warns: a frame subset to fewer PSUs than the weights were built from is not a mislabelling.JKn coefficients are derived when the declared units allow it.
(n_h−1)/n_his the only standard coefficient that is not closed-form inn_reps, so it used to have to be supplied by hand. Givenkind="jkn"plus thestratumandpsuthe replicate weights name (not the Design’s — see above), svy now works it out atSampleconstruction. Akind="jkn"with no units named, or with nothing derivable from them, warns at construction and raises fromcoefficients(). Balanced designs — every stratum the samen_h— take a fast path where the coefficient is uniform and the replicate-to-stratum mapping never has to be found.Unbalanced strata are not a different method, just JKn with a coefficient that varies by stratum, so the only thing missing is that mapping — and a delete-one-PSU replicate states it by zeroing the PSU it drops. svy now reads it back: one aggregation to PSU level, and the recovered vector is identical to what
create_jk_wgtswould have assigned, to the last bit. Only replicate → stratum is needed, since the coefficient is constant within a stratum.It applies only where the delete-one signature holds — every replicate column zeroing exactly one otherwise-nonzero PSU, no PSU dropped twice. A bootstrap, a BRR or a Fay-style variant fails that and falls back to refusing, rather than being handed a confidently wrong vector.
scaleremains the answer for files that do not zero.A declared kind is also checked rather than merely trusted:
jk1andjknimply one replicate per PSU,jk2one per stratum. Mismatches warn rather than raise, since a legitimately subset frame has fewer PSUs than the weight columns were built from. An unspecified kind on a stratified design warns too — svy has evidence the JK1 global is probably wrong, but no claim to act on.
Fixed
kind="jk1"against stratified units silently returned the unstratified coefficient.jk1andjknimply the same replicate count — one per PSU — and differ only in stratification, which the replicate-count check never looks at. So the one mismatch it structurally could not catch was the expensive one:jk1on units declaring several strata used(R−1)/Rwhere(n_h−1)/n_his correct, overstating standard errors bysqrt(R/n_h)— 32% on a 4-strata × 2-PSU design — with no warning. Naming astratumunit says the replicates were drawn within those strata andjk1says they were not, so it now raises and namesjkn. Replicates genuinely drawn without regard to strata are declared by naming onlypsu.A declared JKn with a psu but no stratum silently produced the JK1 coefficient.
(n_h−1)/n_his a per-stratum quantity; with no stratum named, the PSU count fell back to a single group holding every PSU and the derivation returned(R−1)/R— the JK1 global — under a JKn label. On 4 strata × 2 PSUs that is 0.875 where 0.5 is correct, an 8% error in the standard error, with no warning. It now refuses, the same way an unmetkind="jkn"claim already did.Building BRR or JK2 weights no longer destroys the design strata.
create_variance_strataended by overwritingDesign.stratumwith the collapsed variance strata — its only way of handing the result to the generator that ran next. Every later Taylor estimate then silently linearized over pseudo-strata: on a 6-strata × 4-PSU frame the SE moved from 0.858 to 0.641 and df from 18 to 12, with no warning and the original column still sitting in the frame. Pairing is now internal and its output is recorded on the replicate weights, soDesign.stratumis Taylor’s alone and is left exactly as declared.create_jk_wgts(paired=True)no longer undercounts replicates. Called on strata with more than two PSUs — i.e. without the old pairing pre-step — it produced one replicate per original stratum instead of one per variance stratum, in silence: 6 where 12 were correct, understating the variance. It pairs first now. This changes published standard errors for anyone who called it without the pre-step.create_brr_wgtsno longer requires a stratum. It raisedBRR requires 2 PSUs per stratumon any unpaired design, andstratum=Nonewas rejected outright with a hint to go and build variance strata by hand. Both cases now pair and build.A row-level
order_bycolumn paired PSUs more than once. Uniquing on the whole row left the same PSU present several times, so it was assigned to several variance strata and some ended up holding a single PSU — which the BRR kernel then rejected. First occurrence in the frame now wins per PSU.Renaming or dropping a unit column follows through to the replicate weights.
_rep_wgts_with_renamesonly remappedprefixand returned early when no replicate column matched the rename — the common case, since renaming a variance-stratum column touches no replicate column. The units were also absent from the protected set, so dropping one was neither blocked nor cleaned. They are now rewritten on rename, needforce=to drop, and are cleared rather than left dangling when force-dropped.method=Noneno longer auto-selects replication, and Taylor on a replication-only design says so. The default resolved to replication whenever the design carried replicate weights and had nostratum/psu, and to Taylor otherwise. That made the estimator depend on inputs the estimator never reads — replication consumes the replicate columns andcoefficients(), nothing else — so declaring a single design column silently moved the standard error:mean("y"), identical replicate weights throughout wgt only -> Jackknife se=1.252718 wgt + stratum -> Taylor se=1.171192 wgt + psu -> Taylor se=1.002973 wgt + stratum + psu -> Taylor se=1.063071It was worst for JKn, where
stratumandpsuare exactly what svy needs to derive(n_h−1)/n_h: the one action that makes JKn usable was the action that switched JKn off.Noneis now the unstated Taylor default the signatureLiteral["taylor", "replication"] | Nonealways implied, not a third mode. Replication is opt-in: passmethod="replication".Falling through to Taylor alone would have reinstated the worse half of the original bug, which is why the auto-detection existed. Without
stratumorpsuevery row is its own PSU in one stratum, so linearization is SRS-like — on a 200-row file with 8 replicates,df=199against a true 7, silently. That case now emitsTAYLOR_WITHOUT_DESIGNand proceeds, for the explicitmethod="taylor"spelling too: the hazard is in the number, not in who asked for it.A JKn design that cannot be derived warns at construction.
kind="jkn"with nopsuto count from, or with unbalanced strata, built aSamplein silence and failed only when an estimate was requested — at a call site far from the cause. Both dead ends now emitJACKKNIFE_COEFS_UNAVAILABLEnaming which one it hit. TheMethodErrorfromcoefficients()stays lazy on purpose: such aSampleis still perfectly usable for Taylor, for wrangling and forwhere=, so raising at construction would block work that never touches replication. What was missing was the early signal, not the error.The JKn error names both remedies, not one. It named only
scale=, sending anyone whose file does carrypsuoff to hand-compute coefficients svy would have derived for them. It now offers declaringstratum/psufirst, andscale=for the unbalanced case where that cannot help.create_bs_wgts(kind="poisson")fails cleanly against an oldersvy-rs.rust_create_poisson_bs_wgtswas missing from theImportErrorfallback, so the name was never bound and the guard raisedNameErrorinstead of the intended message.MethodError.not_applicableno longer doubles the sentence-ending period. The template appended.to areasonthat ~24 call sites already ended with one, producing…(got psu=None)...RepWeights.dfis annotatedfloat, matching what it has always stored. It was declaredint | Nonewhile the kernels hand back an f64, so a repr readdf=499.0against anintannotation. Widened rather than coerced:dffeeds a t-quantile, which is defined for fractional df, and Satterthwaite-style effective df is fractional by construction. Anintis still accepted and stored unchanged.A user-supplied replicate coefficient was silently discarded for bootstrap, BRR and SDR (#7). Before #131 the override was applied at one site in the kernel —
rscales.unwrap_or_else(|| replicate_coefficients(method, n_reps, fay_coef))— and was therefore method-agnostic by construction. #131 moved coefficient computation into Python and scattered it across four per-variantcoefficients()methods, which gave every method its own chance to forget. Three of four forgot: the field was accepted, stored, length-checked, and then dropped, so a bootstrap declared with a producer’s published scale silently returned the1/Ranswer.coefficients()is now a template method on the shared base. The override is resolved there, at the single point of use, and the variants implement only their own default — they never see it, so a new variant cannot regress this again. No published standard error changes: #131 landed aftersvy-v0.24.1and was never released.Estimation and regression could scale the same design differently.
GLM._rep_coefsre-derived the coefficients by substring-matching a stringified method tag (if "boot" in m), and honoured the user’s override for every method while the estimation path did not. OneDesigntherefore produced differently scaled standard errors fromsample.regression.glm(...)andsample.estimation.mean(...). It is gone; both callrep_wgts.coefficients().A declared paired jackknife used the wrong coefficient. JK2 has one delete-one replicate per stratum and a coefficient of
1.0; the global(R−1)/Runderstates the variance by exactly that factor. Declaringkind="jk2"now gets1.0. Relatedly,kind="jkn"with nothing to compute the per-stratum coefficients from — noscale, no design variables — refuses rather than substituting the JK1 global, which on a 4-strata × 2-PSU design overstates standard errors bysqrt(7/4), 32%. An unmet claim fails; an absent one (kind=None) still falls back to(R−1)/Rexactly as before.Labelled variables were never
is_categorical(#130).Sample(data=df)infersmtypefrom the polars dtype, andimport_labels_from_svyio_metabrought across labels while leaving that untouched — so every labelled variable in a survey recode stayedNumerical Continuousandis_categoricalwasFalse. Deriving a codebook from it classified an entire DHS-style file as continuous. The importer now revisesmtypeon import: it honours a measurement level the file declares, and otherwise asks whether the value labels cover every observed value. On a Stata census file this takes 16 labelled variables from 0 categorical to 16.Measurement type is inferred from label coverage, not label presence. “Has labels” alone would misread a continuous variable that labels only a sentinel code — a “how many TVs?” count labelling just
0 = noneand4 = 4 or moreis not a five-level factor. A variable becomesNOMINALonly when every observed non-null value carries a label; partial coverage, an all-null column, and a column absent from the frame all leavemtypealone, as does a type the user set by hand.ORDINALis assigned only when the file says so, since coverage cannot distinguish it fromNOMINAL.The polars deprecation filter never worked.
filterwarnings = ["error::DeprecationWarning:polars"]looked like a guard against calling deprecated polars APIs and was inert: the module field is compared against the frame the warning is attributed to, and polars setsstacklevelto point at the calling code, so:polarsnever matches. Verified with a probe — a deprecated call passed cleanly under it. The filter is now unqualified, and the suite passes under it.
Changed
Replicate-weight designs are a tagged union of four types, and
svy.RepWeightsis a function (#131). They were a single struct carrying an estimation-method enum plus every method’s parameters. They are now one type per method —BootstrapWgts,JackknifeWgts,BrrWgts,SdrWgts— each carrying exactly the parameters its algorithm has. A bootstrap cannot hold a Fay coefficient, because there is no field for one.svy.RepWeights(method=..., ...)keeps working and returns the variant for the name, so call sites are unaffected. But it is a factory function now, not a class, which breaks two things it used to support:isinstance(x, svy.RepWeights) # TypeError: isinstance() arg 2 must be a type x: svy.RepWeights # no longer a valid annotationUse
svy.RepWgts, the union of the four variants, for both. Code that knows the method at authoring time can construct the variant directly, which is the typed path:BootstrapWgts(prefix="bsw", n_reps=1000, kind="poisson").rscalesis nowscale(user-supplied) orrep_coefs(svy-derived). The single field held both, which made “the user asserted this” indistinguishable from “svy computed this”. They split on who supplied the values — the one axis that is verifiable, since a user declaring stratified JKn supplies the standard coefficient rather than a custom one.scaleis what you pass;rep_coefsis filled bycreate_jk_wgtsand by the JKn derivation, and is shown as(derived)in the design output.This renames a parameter on two released surfaces:
svy.RepWeights(rscales=...)andDesign.update_rep_weights(rscales=...). Both now takescale=(orrep_coefs=), andscaleadditionally accepts a scalar.A parameter that belongs to another method is refused, even at its neutral value.
RepWeights(method="bootstrap", fay_coef=0.0)was accepted as a stored no-op when every method shared one flat signature. It now raises, naming the variant that owns the parameter. Callers that know the method should not be sending it; nothing insvydoes any more.The
methodlabel is display-only, and now genuinely is. Its docstring said nothing read it to choose a code path while eight sites did — seven inweighting/rebuilt a design by round-tripping the type through its own tag and hand-listing every field that had to survive, which is howkind,scaleandpaddingcould vanish across a poststratification. Copies now go throughmsgspec.structs.replace, which preserves the type and drops nothing.Non-default variance coefficients are visible.
scaleandrep_coefsdid not appear inreprorprint(design)at all. A non-standard coefficient is what a reviewer or replicator most needs to see and is set rarely, so rare-and-silent was the wrong combination. A uniform vector prints as the scalar it is.Design.update_rep_weightsis a third the size, with the same behaviour. It resolved nine sentinel parameters by hand and rebuilt the variant from a method name, because seven internal callers needed exactly that. They now usemsgspec.structs.replace, so it keeps only the two jobsreplacecannot do — changing the method, and creating weights where there are none. Unchanged for callers: the same signature, the same first-time error, and a variant-specific field still carries only within the same method.import_labels_from_svyio_metanow requires the frame the metadata describes as its third argument. Resolving a measurement type depends on how well the value labels cover the observed values, which cannot be judged without the data. It is required rather than optional on purpose: an omitted frame would silently fall back to the very behavior this release fixes. The function is internal (svy.engine.io, not exported from the top-levelsvynamespace) and had a single production call site.
Removed
BREAKING:
Sample.weighting.create_variance_strata. Undocumented — no mention in any changelog, README or guide — never called by svy itself, and reachable in practice only through a hint insidecreate_brr_wgts’s error telling you to call it. It was a mandatory pre-step svy made you perform by hand and then punished by clobberingDesign.stratum. The pairing algorithm survives as a private helper with its edge cases intact (odd PSU counts, tuple strata, ordering, reproducible shuffling); itsorder_by,shuffleandintocontrols moved ontocreate_brr_wgtsandcreate_jk_wgts, where they are discoverable.into=is nowstratum_name=.# before # after s = s.weighting.create_variance_strata( s = s.weighting.create_jk_wgts( method="jk2") paired=True) s = s.weighting.create_jk_wgts(paired=True)svy.core.design.make_rep_weights. A strictly weaker duplicate ofsvy.RepWeights— same job, but only four of the parameters, so it silently droppedscale,rep_coefsandkind. It was never exported fromsvyorsvy.core, so it was reachable only asfrom svy.core.design import make_rep_weights, and had no callers in the package. Usesvy.RepWeights(method=..., prefix=..., n_reps=...), which takes the same three positionally.
0.24.1 — 2026-08-11
Fixed
Value labels survive being written. Reading back what
apply_labelshad just stored raisedAttributeError: 'int' object has no attribute 'code'(#127):s.wrangling.apply_labels(categories={"q1": {1: "Yes", 2: "No"}}).labelsVariableMeta.clonecopied throughmsgspec.structs.replace, which does not run__post_init__before msgspec 0.21 — and the floor was>=0.19.0.__post_init__is the one place that turns the authoring dict{1: "Yes"}into theValueLabelpairs the field is declared to hold, so on a resolved 0.20 the dict was stored verbatim and the failure surfaced at the next read rather than at the write. Sinceapply_labelsreachesclonethroughset_value_labels, the whole documented path for labelling a variable was affected on that msgspec.Cloning now goes through the constructor, which normalizes and re-checks the invariants by definition, on any msgspec. The floor moved to 0.21 as well (see below) — two fixes for one defect, deliberately: the pin closes it for
replaceeverywhere in the codebase, and the constructor closes it here regardless of what the pin later becomes. Applied toVariableMeta,LabelandCategoryScheme— the three structs that normalize in__post_init__.
Changed
msgspec floor raised to 0.21.
msgspec>=0.19.0becamemsgspec>=0.21.0(#127).0.21 is the first release where
structs.replaceruns__post_init__; 0.19 and 0.20 skip it, verified directly against each. Any struct that normalizes or validates in__post_init__is therefore only as sound as the resolved msgspec, and svy has several — the value-label defect above is what that looks like in practice, and it reached users because a lockfile resolving 0.21 hid it from the test suite while a docs environment resolving 0.20 published the traceback.Nothing in svy needs a 0.20 or 0.19 runtime, and 0.21.1 was already what every lockfile here resolved, so this only rules out an installation that was never tested.
0.24.0 — 2026-08-10
Added
corrandcov: design-based correlation and covariance (#124). Both are available onsample.estimationwith Taylor and replication variance,by=,where=, domains anddeff.Neither statistic has a direction, so neither takes
y/x. A call names a set of columns through one symmetriccolsargument, and every requested pair is returned as its own row:sample.estimation.corr(("income", "age")) # one pair sample.estimation.corr(["income", "age", "educ"]) # every unique pair sample.estimation.corr([("income", "age"), ("income", "educ")]) # exactly theseThe three spellings are disambiguated by element type and agree wherever they overlap, so a two-element list and a two-element tuple mean the same thing. A flat list yields off-diagonal pairs only — for covariance as much as correlation — so a variance is requested explicitly as
cov(("a", "a")).kind=selects the coefficient ("pearson"today) whilemethod=remains the variance estimator. Since pandas spells the coefficientmethod=, that mix-up is caught by name and redirected; a recognised but unimplemented coefficient reports that it is not supported yet rather than invalid.Correlation is bounded, so its interval is built on Fisher’s z scale and transformed back — the same move
propmakes with a logit, and for the same reason: a symmetric Wald interval can otherwise report bounds outside [-1, 1].ci_method="wald"opts out. Covariance is unbounded and takes Wald.Validated against R survey 4.5: covariance and its SE against
svyvar, correlation and its SE againstsvycontrast’s own delta method over the moment means — R has no correlation SE of its own, so that agreement is an independent check of the linearization rather than a restatement of it — plus stratified and JK1 replicate fixtures. AddsPopParam.CORRandPopParam.COV.
Changed
BREAKING:
deffnow names the SRS reference instead of taking a boolean.deff=Trueanddeff=Falseare rejected with a structuredMethodErrorthat names the replacement; the argument takes"wor","wr"orNone.before now deff=Truedeff="wor"— without replacement, Kish’s design effect. Same numbers as before.deff=Falseomit the argument, or deff=None— deff="wr"— with replacement, the square of Kish’s “deft”A boolean never said which reference it meant. Accepting it silently would leave two spellings for one thing indefinitely, and ignoring it would be worse:
Literalis not enforced at runtime, so a string-only implementation would readdeff=Trueas “off” and quietly stop reporting a design effect the caller asked for. Rejecting it loudly is the only option that cannot mislead."replace"is accepted as an alias for"wr", since that is R’s spelling.The reference describes the denominator — what the design variance is compared against — and says nothing about how the sample was drawn;
deff="wr"is not a claim of with-replacement sampling. The two differ by exactly the finite-population correction1 - n/N, so they agree closely at small sampling fractions and diverge sharply otherwise: at f = 0.8, an evaluation-study shape, they differ five-fold (5.027 against 1.005, both matching R)."wor"infersNfrom the sum of the weights, so it is meaningful only while those weights remain reciprocals of selection probabilities. Afternormalize, or to a lesser degree raking or calibration, that sum is no longer a population count and the design effect is silently wrong — svy cannot detect this in general, since weights normalized to twice the sample size pass every check and yield a plausible wrong answer."wr"has noNin it and is unaffected. The one provable case now raises rather than returning a column of NaN: when the weights sum to no more than the sample size, the correction is zero or negative, which means either rescaled weights or a census with no sampling variance to compare against.The reference is recorded on the result. It appears in the printed header beside the variance method —
Estimate: MEAN (TAYLOR, deff=wr)— is available asEstimate.deff_ref, and round-trips through serialization as an optionaldeff_reffield.to_polars()is deliberately unchanged: that frame carries no method, param or design either, and a deff column there was already provenance-free.Error codes default per class.
SingletonErrorandSvyWarningsErrorfell back to the baseSvyErrorcode when raised without an explicit one, so two distinct failures could surface under the same identifier. Each now carries its own default (#123).svy.serializeraises svy’s structured errors instead of bare built-ins. The serialize module was the last public surface still raisingTypeError/ValueErrorwhere the rest of the library uses theSvyErrorhierarchy — structured errors with a stablecode,expected/gotcontext, and a hint. The newSerializationError(exported assvy.SerializationError) covers the serialize-specific failures, and the unfitted-model case now reuses the sameModelErrorthatGLM.predict()already raises:call before now code serialize(<unsupported type>)TypeErrorSerializationErrorUNSUPPORTED_RESULT_TYPEserialize(<unfitted GLM>)ValueErrorModelErrorMODEL_NOT_FITTEDfrom_json(<no "kind">)ValueErrorSerializationErrorPAYLOAD_MISSING_KINDfrom_json(<unknown "kind">)ValueErrorSerializationErrorPAYLOAD_UNKNOWN_KINDBreaking for callers that catch the old built-ins:
SvyErrorsubclassesException, notTypeError/ValueError. CatchSerializationError/ModelError(orsvy.SvyErrorto cover every svy failure) instead, or match on thecodefield, which is the stable contract.
0.23.0 — 2026-08-05
Added
sample.estimation.quantile()— design-based quantiles with standard errors (#112). Previously only the median carried a standard error; every other quantile was available as a point estimate throughdescribe(percentiles=), with no variance.quantile()estimates any set of probabilities under Taylor linearization or replicate weights, withby=domains andwhere=filters:sample.estimation.quantile("income") # quartiles, the default sample.estimation.quantile("income", p=0.9) # a single Estimate sample.estimation.quantile("income", p=(0.1, 0.5, 0.9), by="region")Standard errors follow Woodruff (1952), the construction behind R’s
svyquantile: the design-based variance of the estimated proportionP(Y <= q)is taken on the probability scale, and the interval comes from inverting the weighted CDF atp ± t·se_p. The reportedseis the back-solved half-width.pfollows the same rule asy: a scalar returns oneEstimate, a sequence returns one per probability.median()is unchanged and remains thep = 0.5case reported asPopParam.MEDIAN.EstimateList— thelistsubclass now returned wherever a call estimates several things at once (mean(["a", "b"]),quantile(y, p=(...))). Printing a bare list previously showed object reprs ([<svy.estimation.estimate.Estimate object at 0x…>, …]); anEstimateListrenders its members as one table and addsto_polars(). It is alist, so indexing, iteration, unpacking, andisinstance(result, list)are unaffected.Serializing a multi-estimate result also works for the first time —
serialize()dispatches on exact type and previously raisedTypeError: No serializer registered for list. The newestimate_listkind wraps the members, each serialized exactly as a standaloneEstimate.
Fixed
Quantile and median confidence limits now invert the CDF with the rule that located the point estimate. The Woodruff interval endpoints were always interpolated linearly, regardless of
q_method. R’soldsvyquantilehands onemethod/fpair to both its pointapproxfunand its endpointapprox; interpolating linearly while estimating with, say,"higher"pulls both endpoints inward and understates the standard error. The error grows where consecutive order statistics are far apart: on a 2000-row stratified design it was 2.0% at the median and 10.3% atp = 0.99, always in the anti-conservative direction. Estimates, standard errors and both confidence limits now agree with R to machine precision at every probability tested from 0.01 to 0.99, including domains.This changes
median()standard errors and confidence limits. Point estimates are unaffected.The Woodruff linearization is centered on its own weighted mean, matching how R computes the variance (
svymean(U, design)). Only thelinear,middleandnearesttie rules moved — up to 4e-4 relative on the confidence limits — because thehigher/lowerinversion snaps to an order statistic and absorbed the difference.median()and default-q_methodresults are unchanged by this one. Seesvy-rs.
0.22.1 — 2026-08-04
Changed
Taylor estimation uses the cores it is given. On a 10-core machine at 1M rows a single-variable mean used 1.15 cores and an 8-variable batched mean reached 1.8 of a possible 8. None of it was a rayon width problem — three pieces of redundant serial work sat around the fan-out, and removing them is what freed the parallelism:
sample.estimationreturned a newEstimationon every attribute access, so the_data_version-keyed caches it carries — the factorized design arrays and prepared design info — were discarded before they could ever be reused, and everysample.estimation.mean(...)re-derived the whole design. The accessor is now retained perSample. Derived samples are handled by an identity check:_replace_dataforks withcopy.copy, which carries the cached accessor over verbatim, and without the check a fork would answer with anEstimationstill bound to its parent’s data.- The reporting metadata on each
Estimate(unique stratum labels, PSU count) was computed withnp.uniqueover the full-length design arrays per estimate. A batched call produces oneEstimateper variable, so an 8-variable mean did 16 full-length passes — 63% of that call’s wall time at 1M rows, all serial. These are properties of the design, not of the variable, so they are memoised on the design cache and invalidate with it. - The Rust kernels stopped indexing the design twice per estimate and now overlap their independent halves — see
svy-rs.
Measured on a 10-core M1 Max at 1M rows, 50 strata, 2000 PSUs:
case before after speedup cores used mean, 1 variable 70.9 ms 22.4 ms 3.2× 1.15 → 1.60 total, 1 variable 83.8 ms 27.4 ms 3.1× 1.12 → 1.86 mean, 8 batched 163.2 ms 38.6 ms 4.2× 1.78 → 3.47 mean by group 144.4 ms 131.9 ms 1.1× 3.45 → 3.46 Thread scaling from 1 to 10 threads went from 1.11× to 1.54× for a single variable and 1.58× to 3.03× for the batched call. A single estimate overlaps two halves rather than fanning out, so its ceiling is ~2× by construction.
Estimates, standard errors and degrees of freedom are unchanged — bit-for-bit, and identical at 1, 2 and 10 threads.
One trade-off worth knowing: retaining the accessor means its cached design arrays (~31 B/row) stay alive as long as the
Samplerather than being freed between calls. Peak memory is unchanged — those arrays were rebuilt on every call before, so the high-water mark was always there (2433.8 MB before vs 2434.5 MB after, over six calls at 10M rows). What is new is that a long-lived process holding many largeSampleobjects idle now holds their design caches too.
Fixed
Replication variance no longer costs O(B²) in the replicate count B. The replicate kernels themselves were never at fault — they already do a single O(n·B) pass — but two sites in the Python prep layer tested column membership once per replicate weight column:
prepare_datawith[c for c in rep_weight_cols if c in df.columns], andEstimation._ensure_float64withc in data.columns and data[c].dtype != ....df.columnsis a property that rebuilds the entire column-name list across the FFI boundary on every access, so B lookups cost O(B²) Python-string constructions;_ensure_float64also materialised a Series per column. A cProfile run at n=25,000 / B=800 putPyDataFrame.columnsat 57% of total runtime, called ~2·B times per estimate.With total work n·B held constant at 20M cells — an identical 160 MB replicate weight matrix in every case — the sweep spanned 36× across cases doing identical arithmetic. Column names are now snapshotted into a set once, and dtypes read from a single
data.schemasnapshot that answers both existence and dtype:n B before after speedup 200,000 100 0.0067 s 0.0059 s 1.1× 50,000 400 0.0219 s 0.0077 s 2.8× 25,000 800 0.0704 s 0.0116 s 6.1× 12,500 1600 0.2442 s 0.0201 s 12.1× Sweep spread drops from 36.2× to 3.4×. This matters most for bootstrap designs at B=1000+; it is negligible for BRR (32–64) and SDR/ACS (80). Results are numerically inert — mean, total, ratio, prop and mean-by-domain are bit-for-bit identical to the previous build.
0.22.0 — 2026-08-02
svy labels values so results print nicely. That is the whole job. Everything that made a label list shareable — concepts across locales, hierarchies, the semantics of why an answer is absent — moves to svy-spec, where a questionnaire gives it meaning. A catalogue without one is machinery with no job.
If you set labels by hand, read them from a .sav/.dta, or print them on estimates, nothing you do changes. If you used missing-value definitions, see Removed.
0.21.1 was never published. It was numbered and its notes written on 2026-07-27, then further work landed before a tag was cut. Its changes ship here, and are kept below under their own headings so nothing is lost — but no
svy==0.21.1exists to install, which is why there is no section for it.
Added
MetadataStore.update(other, *, overwrite=False)— merge one store into another, field by field.Metadata for a variable arrives from several places that each know a different part of it: measurement types inferred from the data, missing-value codes declared by the analyst, question wording carried by an instrument spec. The only way to combine two stores was
set, which replaces a wholeVariableMeta— so applying a spec silently cleared missing codes, because a questionnaire has no concept of them and its record carriesmissing=None. That loss is invisible until an export drops the declarations.updatemerges per field, which means a source can only ever add what it knows and can never clear what it has no opinion about.overwrite=False(the default) fills only gaps, keeping labels you have already chosen;overwrite=Trueletsotherwin where both are set — for a spec whose question wording should be definitive. A fieldotherhas not set is left alone in either mode, which is the property that makes the merge safe.store.update(other) # fill gaps only store.update(other, overwrite=True) # `other` wins on conflicts
Changed
VariableMeta.value_labels,ResolvedLabels.value_labelsandLabel.categorieshold(code, label)pairs, with a mapping accepted when constructing and a dict view for lookup —.labels,.labels,.label_map. JSON object keys are always strings, so adict[Category, str]wrote{101: "Banjul"}and read it back with a string key, silently, after which every join against an integer-coded column missed.Label.categoriesalso dropped_MissingTypefrom its union: msgspec refuses to decode any union containing a custom type, so the struct raised regardless — and nothing ever set the field to the sentinel, sinceNonealready meant “no value labels”.CategorySchemeholds one entry per code,SchemeEntry(code, label), replacing themapping/missing/missing_kindscollections keyed by code. Three of those wereCategory-keyed dicts or sets that survived only through a hand-written encoder; every field is JSON-native now, so the bespoke encoder is gone —to_bytesis a singlemsgspec.json.encode— and a scheme keeps its code types by any route.
Removed
MissingDef, and every API keyed on it:VariableMeta.missing,.na_as_level,.has_missing,.with_missing;ResolvedLabels.missing_codes,.is_missing,.non_missing_labels;MetadataStore.set_missing,.set_na_as_level; the same two onSample; thehas_missingcolumns insummary()andcoverage().svy.metadatano longer exportsMissingDeforMissingKind(the enum stays insvy.core.enumerations).A 99 labelled “Refusal” is the integer 99 with the label “Refusal”. svy reads it, prints it, and forms no opinion. Absence is a polars null, which needs no metadata.
The evidence for removing rather than slimming: import never populated it. svy-io surfaces
MissingRuleandTaggedNA, and svy’s importer reads variable labels and value labels only — so the field’s only source was a hand-set value or svy-spec’s bridge, and its only consumer was writing it back out. Nothing ever acted on it: a declared code did not change an estimate then, and does not now.# before store.set_missing("age", dont_know=[98], refused=[99]) # after — the code is a value, and a value needs a label store.set_value_labels("age", {98: "Don't know", 99: "Refused"})To declare user-missing in an exported
.sav, pass it at the boundary that has the concept:svy_io.write_sav(..., user_missing=[...]).locale, everywhere.CategoryScheme.locale,SchemeRef.locale,LabellingCatalog(locale=)/.locale/.set_locale, thelocale=argument onpick,list,add_scheme,make_scheme,MetadataStore(default_locale=),MetadataStore.set_scheme, andSample.use_scheme.svy does not translate. All
localedid was choose between two schemes registered under one concept — and two concept names do that with no matching algorithm. A label is a string: write"Femme"and svy prints"Femme". svy holds one set of strings; choosing which set is svy-spec’s job.catalog.add_scheme(concept="sex_en", mapping={1: "Male", 2: "Female"}) catalog.add_scheme(concept="sex_fr", mapping={1: "Homme", 2: "Femme"})CategoryScheme.id— it only ever meantconcept:locale. The catalogue is keyed by concept now, one concept holds one scheme, andpick()is a lookup.get,removeandto_labeltake a concept where they took a scheme id.SchemeEntry.parent,.missing,.is_missing, and the lookups over them:parent_of,children_of,codes_of_kind,kind_of,substantive,missing_codes. A scheme is a code→label map. svy has no cascading selects, and why an answer is absent is a questionnaire fact.CategoryScheme.ordered. Order lives in the codes; “is this ordinal” isVariableMeta.mtype.to_label_by_concept, folded intoto_labelnow that concept is the key.171 lines with no caller anywhere in the monorepo, in svy-spec, or in any test:
is_missing_value,recode_for_analysis,display_text,polars_mask,polars_to_analysis,polars_to_display(none exported),SchemeCatalogView, and the no-op seamsvalidate_scheme_missing,normalize_scheme_missing,missing_codes_by_kind.labels.pyno longer imports polars.svy.questionnaire,MetadataStore.import_from_questionnaire, and theSample(questionnaire=)parameter. Describing an instrument is a different job from analysing the data it produced, and svy had come to own a small piece of it: a flat question model with no notion of rosters, ordered scales, or analysis units. That work now lives in svy-spec, which inverts the dependency — svy no longer needs to know what a questionnaire is.This is a removal without a deprecation cycle, which the version number alone does not convey.
Questionnairewas exported fromsvy.questionnaire, but never from the top-levelsvynamespace, never documented, and never used anywhere in svy beyond the oneSample(questionnaire=)hook — which only forwarded toimport_from_questionnaire. A patch bump reflects a path with no known consumer; if you were importing it, pinsvy==0.21.0and migrate at your convenience.To attach instrument metadata, resolve a spec, project it, and merge it in:
from svy_spec.bridge import to_metadata_store from svy_spec.resolve import resolve sample = svy.Sample(data, design, catalog=catalog) sample.meta.update(to_metadata_store(resolve(spec), catalog=catalog), overwrite=True)Use
updaterather than a loop overset: it merges per field, so a field the spec does not model — missing codes you declared, notes you added — is never cleared by applying it.Pass
overwrite=Truewhen the spec is the authority, which it is here.Sample.__init__runsinfer_from_dataframe, which setsmtypeby guessing from each column’s storage type; under the default fill-only merge that guess wins and the spec’s declared level never lands, so an ordered single-select stays Numerical Discrete instead of becoming Categorical Ordinal. Apply the spec first, then any adjustments of your own.MetadataSource.QUESTIONNAIREstays — it is what the bridge sets, and it remains the right provenance for a field-collected variable.
Fixed
The SPSS and SAS writers could not run at all.
_write_spsscalledsvy_io.write_spssand_write_sascalledsvy_io.write_sas; neither name has ever existed. Both calls carried# type: ignore[attr-defined]— the type checker had said so and been silenced.The real API is
write_sav(df, path, *, var_labels, value_labels, user_missing, ...), taking labels as separate arguments rather than onemetadatadict.SAS is more than a rename: ReadStat writes SAS Transport (XPT) only — there is no
sas7bdatwriter — and XPT carries no variable or value labels._write_sasnow writes XPT and warns that the labels did not travel, pointing atwrite_spssorwrite_stata.format=andencoding=are reported as ignored, and_write_spssloses itsencodingparameter, whichwrite_savdoes not take.It survived because the only test used a stub that defined
write_spssandwrite_sasitself. A stub that invents the interface it stands in for cannot catch a call to a function that is not there.A labelled
Table.crosstab()returnedNonefor every estimate. The frame’s rows were replaced with labels while the skeleton they are joined against stayed as codes.A value label did not apply when the code and the value disagreed in type. SPSS stores value-label keys as strings, so a
.savread back gives{"1": "Yes"}against aFloat64column, andResolvedLabels.displayreturned the bare number.displaynow bridges both directions.display_serieswas never affected.ttest_to_markdown()raisedNameErroron any call — it referenced a_stats_summary_linethat does not exist. The docstring promised a summary line the code never had; both are gone.SingletonResult.confignamed a class that does not exist (_SingletonHandlingConfigforSingletonHandlingConfig). Latent only becausefrom __future__ import annotationsleft it unresolved; it would have broken onget_type_hintsor a typed decode.Two tests shared a name, so the second replaced the first and one never ran. Three further tests had no assertions at all.
ruff checkpasses onpackages/svy/{src,tests}for the first time.
0.21.0 — 2026-07-24
Requires svy-rs 0.12.0, which carries the two variance-estimation fixes below; svy-io 0.2.0 is unchanged. Grouped confidence intervals and domain design effects change in this release — point estimates and standard errors do not.
Fixed
by=groups now use their own degrees of freedom. A by-group is a domain, so its df must be counted on the PSUs and strata that group covers. It was instead given the df of the surrounding analysis — the full design with no filter, or thewhere=mask with one — so the same subpopulation got a different interval depending on whether it was reached throughby=orwhere=. Confidence intervals for grouped means, totals, ratios, proportions and medians were consistently too narrow; the effect is negligible for groups spanning most of the sample and large for small ones (22% on a 10-record domain with 6 df rather than 56).est,se,cvare unaffected. Verified against Rsurvey4.5degf(subset(design, ...)).- Design effects no longer count zero-weight rows. Under
drop_nulls, rows with a missing response are kept and zero-weighted rather than dropped; they were still counted in the domain SRS variance’sn, inflatingdefffor any group containing them (~1–2% on the synthetic fixtures). Onlydeffis affected.
Removed
Estimate.degrees_freedom. Degrees of freedom are a per-row property — a domain or by-group is counted on its own active PSUs and strata, so grouped results legitimately carry a different df per cell. The scalar could not represent that: it wasmin()across rows, so a grouped estimate reported its smallest group, and for a by-group inside a domain that meant a headline df of 0. UseParamEst.df(also adfcolumn into_polars()) for the per-row value, andn_psus - n_stratafor the full-design df, which stays at design level under a domain filter.EstimateData.degrees_freedomleaves the serialized payload;SCHEMA_VERSIONmoves tosvy-result/0.2. Strictly a field removal warrants a major bump under the policy inserialize/DESIGN.md; 0.2 was chosen deliberately because no known consumer binds to the field, and the reasoning is recorded there.
Added
ParamEst.df— the design df backing each row’s t-quantile, carried throughto_polars()and the serialized payload. It is deliberately not shown in the printed table: it is constant for most results, so a column would repeat one number down the page and widen every table.
0.20.1 — 2026-07-23
Patch release on top of 0.20.0; svy-rs (0.11.0) and svy-io (0.2.0) are unchanged.
Fixed
tabulatepercent andcount_totalcells used an un-centered variance. A cell percentage is a ratio of two estimated totals, so its variance needs the centered (Hájek) linearization. Because the internal totals flag was inferred fromsum(weights) != 1, scaling weights to sum to 100 (units="percent") or to a caller-suppliedcount_totalrouted the standard error through the un-centered total path, dropping the numerator/denominator covariance term. Cell SEs were inflated by ap-dependent amount (up to ~12% on high-proportion cells) and the confidence interval fell back to Wald, which could dip below zero.units="proportion",units="percent", andcount_total=Nare now the same estimator scaled by a constant and agree exactly; they matchestimation.propand Rsurvey’ssvymean(~interaction(...)). Bareunits="count"is unchanged and still matches R’ssvytotal, and the Rao-Scott chi-square/F test was never affected.
0.20.0 — 2026-07-23
Builds on svy-rs 0.11.0 and svy-io 0.2.0. This release lands the round 7–8 review: correctness fixes across estimation, regression, weighting, size/power, categorical, and the dataset downloader, several of which shift standard errors closer to R survey 4.5.
Added
RepWeights.rscales— exact stratified-JKn variance.RepWeightsgains an optionalrscalestuple (per-replicate variance coefficients, R’sscale×rscalescombined);create_jk_wgtsfills it from the design’s strata and estimation threads it to the Rust kernels. svy-generated JKn weights now reproduce R’sas.svrepdesign(type="JKn")mean/total SEs andmse=TRUEcentering to 13+ digits (df = degf). Absentrscales, each method keeps its global default, so user-supplied replicate weights behave exactly as before unless the file’s documentedrscalesare provided.
Fixed
drop_nullszeroes weights instead of dropping rows (Rna.rm=TRUE/subset()semantics).prepare_dataphysically removed any row with a missing analysis value before the domain machinery ran; under standard skip patterns (ynull outside the domain) this deleted whole PSUs and strata, understating domain SEs — 15% on the reference dataset — and corrupting df. Missing analysis values now keep their rows with main and replicate weights zeroed. Verified against Rsurvey4.5 to 13+ digits; the R-calibrated ttest and ratio fixtures were regenerated with these semantics (the old expectations matched R only on physically-filtered complete-case data).- Float-typed stratum/PSU columns are accepted. Numeric design codes from CSVs (e.g. MEPS
VARSTR/VARPSU) frequently arrive asFloat64; the factorized-design cache cast them straight toCategorical, which polars forbids for floats, crashing estimation with “conversion from f64 to cat failed”. Non-string, non-integer dtypes now route throughUtf8first (float- and int-coded designs produce identical results). - SSU-level FPC is grouped by
(stratum, PSU), not PSU alone. PSU labels are commonly reused across strata, sobuild_fpc_ssu_columnmerged distinct PSUs — valid designs raisedFPC_NOT_CONSTANT, and matchingM_hivalues pooled SSU counts across strata, understating the two-stage SSU FPC. method=Noneauto-detects as documented — replication when the only variance information is replicate weights (no strata/PSU), Taylor otherwise. PreviouslyNonealways meant Taylor, silently giving replication-only designs an SRS-like variance.- Core polish and API consistency (review round 7). Replicate-weight prefix matching is strict
^prefix\d+$(a loosestartswithabsorbed columns likerepwt_flag; a count/n_repsmismatch is a typedDimensionError);set_data/update_data/set_design/update_designrebuild internal concat columns and re-run singleton detection + design validation instead of leaving stale state;describe()reports weighted std/var/quantiles (aweight convention) and computes categorical proportions over all levels;SingletonHandlingenum values are accepted bysingleton.handle();PopSize(psu=..., ssu=None)is accepted for PSU-only FPC;polars_mask()is null-safe; the design-fields cache is bounded (512 entries); importingsvyno longer replaces the host’ssys.excepthook(Rich tracebacks install only onSVY_RICH=1). Deleted the unused content-basedSample.__hash__and the dead_calculate_fpc. - GLM design gaps (round 8). Family-specific unit deviance (matching R
family$dev.resids) and null deviance at the intercept-only fit; deviance/AIC follow Rsurveyexactly (Lumley–Scott dAIC;bicisNone); replicate-weight designs get true replicate variance instead of silently falling back to Taylor SEs;design.pop_sizefeeds per-stratum FPC into the sandwich;Cat(ref=...)with an absent reference level raises a typed error listing observed levels; Cat levels, the response, and the invalid-weight filter are evaluated on in-domain rows underwhere=, eliminating phantom all-zero dummies; covariate/where-column nulls keep-and-zero-weight (preserving PSUs in stratum centering). Validated against Rsurvey4.5 to ~1e-6 or better. - GLM margins rewritten on the fitted frame with delta-method SEs (round 8).
marginsrecomputed from raw sample data with ad-hoc SE formulas; it now averages over exactly the fitted rows (post null-drop, post weight filter, with the domain column), rebuilds interaction columns from counterfactual data, differentiates the full linear predictor for AME, and uses full delta-method SEsg'V(β)gover the design-based covariance (Statavce(delta)convention). Validated against Rsurvey+marginaleffects: points to ~1e-8, SEs to ~1e-4. - Weighting adjustment/calibration/trimming marshalling (round 8).
adjustraises a typed error on unmatched response statuses (was silently encoding them as respondents and inflating weights) and derivesrespondents_onlyfrom the encoded codes (case-insensitive);adjust(trimming=..., update_design_wgts=False)trims the freshly created adjusted weight instead of the caller’s original;calibrate(bounded=True)raisesNotImplementedErrorinstead of being silently ignored; calibration targets are assembled as ordered per-term lists (fixing a “Design matrix label alignment mismatch” on shared numeric codes); the trim-calibrate cycle runs on arrays before writing (a strict non-convergence failure leaves data/design/replicates untouched) and honorsTrimConfig.by/min_cell_size;build_aux_matrixraises on nulls in a continuous auxiliary instead of filling0.0. - Weighting typed errors and sorted control order for the svy-rs 0.11.0 changes:
create_brr_wgtspre-validatesn_repsagainst the Hadamard order (MethodError.invalid_range); raking-bounds violations surface asMethodErrorat all four kernel call sites;normalize()orders control values by sorted group id, matching the kernel andpoststratify. - Wrangling edge cases (round 8).
categorize()closes the outer bin edge (Rcut(include.lowest=TRUE)) so boundary values no longer vanish from tabulations;remove_columns(force=True)cleansdesign.pop_size; a partial replicate-weightrename_columnsraises instead of corrupting theRepWeightsprefix;mutate()specs see same-call redefinitions (dependents no longer read stale values);clean_names()preserves internal concat columns;filter_records()counts and reports Kleene-null-dropped rows;fill_null(strategy="mean")casts integer columns toFloat64for an exact mean;cast(strict=True)raises on lossy float-to-integer casts. - Size and power formulas (round 8).
compare_meansis implemented (was a no-op stub returningNone); non-inferiority sizing keepsepsilonsigned (the old|eps|collapse under-sized NI designs ~5×); the one-mean two-sided clamp that produced astronomically wrongnis removed; one-sided power followssign(delta); pooled two-proportion variance and the optimal allocation ratio are un-inverted; the adjustment pipeline is reordered ton0 → DEFF → FPC → nonresponseso the FPC caps the deff-inflated size towardpop_size; parameter validation (p/moe/sigma/power/deff/resp_rate) raises typedMethodErrorinstead of silently clipping. tabulatecount CIs use the design-df t instead of the normal critical value; with few PSUs (df = 6) count CIs were ~20% too narrow, now matching Statasvy: tabulateand svy’s ownestimation.total.ranktestwith a customscore_fnhonorsby=(each by-level is its own domain, returning one result per level) and group labels reflect the levels actually tested underwhere=/by=(estimates were always correct; only the reported labels were wrong).
Security
- Dataset downloader hardened against a hostile catalog. Slugs from registry JSON flowed unvalidated into cache paths, glob patterns, and tempfile prefixes (a slug like
../../foowrote outside~/.svy/datasets); slugs are now allowlisted at the registry boundary and defensively inpath_for/clear. Downloads without a catalog hash pin the first-seen sha256 (trust-on-first-use) and enforce it thereafter. Plain-http URLs and https→http redirect downgrades are rejected (localhost exempt for development). New error codesDATASET_INVALID_SLUGandDATASET_INSECURE_URL.
0.19.1 — 2026-07-21
Added
- Bundled offline example datasets.
svy.datasets.load/catalog/describenow take asource=argument —"bundled","remote", or"auto"(default: remote if reachable, else bundled). A small, self-consistent synthetic survey — a sampling frame, its household census, and a two-stage sample drawn from that census (design weights sum to the census) — ships inside the wheel, so the docs and your own experiments run fully offline and reproducibly.SVYLAB_OFFLINE=1forces the bundled path. DatasetCatalogtype and richerDatasetmetadata.catalog()returns aDatasetCatalogthat prints as a compact table and drills into any entry’s full metadata with.get(slug)(also.slugs,.to_polars()).Datasetprints as a branded panel and gained anotesfield documenting how a bundled subset was derived from its remote counterpart.
Changed
- All dataset failures route through the
DatasetErrortaxonomy with actionable messages and codes:DATASET_NOT_BUNDLED(lists the available bundled slugs),DATASET_DOWNLOAD_FAILED, andBUNDLED_UNAVAILABLE, alongside the existing not-found, catalog, and integrity errors.
Fixed
SvyErrorpanels render again. The Rich panel path imported its renderers from a module that had since been renamed, so every error silently fell back to plain text; it now renders the branded panel. The panel also stays aligned in HTML/notebook output — the status marker is a width-1 glyph instead of a two-cell emoji — and the title, body, and metadata are spaced for readability.
0.19.0 — 2026-07-12
Added
- Batched multi-variable estimation.
estimation.mean,total,ratio,prop, andmediannow accept a list of columns and return alist[Estimate](one per variable;ratiopairs numerator/denominator element-wise and broadcasts a scalar side). A single string still returns a singleEstimate. For ungrouped Taylor estimation the list form shares one design build across variables and runs them in parallel — 4–13× faster than a manual loop at 1M rows depending on the estimator.by=, replication,drop_nulls, and the singleton scale double-pass transparently fall back to independent per-variable calls (identical results). - A variable may now appear in both
by=andwhere=.whereis domain estimation (out-of-domain weights zeroed) andbygroups on the original values — the two are orthogonal, so the previous guard forbidding overlap is removed. When awherepredicate excludes an entirebylevel (e.g. a “don’t know” code), that level is correctly absent from the results — matching R’sfilter(...) %>% group_by(...)— while every row still contributes to the shared design and degrees of freedom, so surviving groups’ estimates, standard errors, and df are byte-identical. Covers Taylor and replication, all estimands, and multi-by. - Serialization for result objects. New
svy.serializemodule provides stable, versioned serialization of every result type (estimates, t-tests, chi-square, tables, GLM fits/predictions, describe):serialize(result)returns a kind-tagged struct,to_json/to_dictexport, andfrom_jsonround-trips. Payloads carry aSCHEMA_VERSIONfor forward compatibility. - Single-stage designs and explicit population sizes. The design’s
ssu(second-stage unit) is now optional, so single-stage designs no longer need a placeholder. APopSizetype is exported for specifying finite-population sizes (FPC).
Changed
- Estimation now fails fast on unhandled singleton PSUs instead of silently under-reporting the variance. Taylor estimation (
mean,total,prop,ratio,median) raisesSingletonErrorwhen a design has single-PSU strata and no handling strategy was chosen — matching R’soptions(survey.lonely.psu = "fail"). Pick a strategy explicitly withsample.singleton.skip()/.certainty()/.center()/.scale()/.collapse()/.pool(). Previously such strata were dropped from the variance with no error or warning.
Fixed
- Taylor standard errors are now bit-reproducible. The stratified variance summed each stratum’s PSUs in the iteration order of a randomized hash set, so a repeated estimate on identical data could differ in its last digits run-to-run (far below reporting precision, but not reproducible). PSUs are now summed in a canonical order, so
mean/total/ratio/prop/medianreturn identical standard errors across runs. - Stale design cache could return silently wrong results. Estimation design caches were keyed on the identity of the data frame without holding a reference to it; after an in-place mutation freed and reallocated the frame, identity reuse could make a stale entry look current and serve design arrays for the old data. Caches are now keyed on a monotonic per-
Sampledata version bumped on every rebind, so every mutation, weighting, selection, and fork path invalidates correctly. - Replication-design crashes and related correctness fixes. Clone, column keep/remove/rename, and singleton handling now work on replication designs (previously hit stale replicate-weight API usage and could crash).
Exprnow raisesTypeErroron boolean use (and/or/not/chained comparisons) so a malformedwhere=predicate fails loudly instead of silently filtering wrong, and derived samples deep-copy metadata/warnings/design so they no longer share mutable state with the original.
0.18.2 — 2026-05-20
First release tracked in this changelog. For the history prior to 0.18.2, see the Git tags and GitHub Releases.