Skip to content

seroepi.aggregate & estimators

Stratified isolate aggregation, frequentist and bootstrap confidence intervals, JAX/NumPyro hierarchical Bayesian partial-pooling models, clonal outbreak deduplication, and Leave-One-Stratum-Out (LOSO) multi-valent formulation optimization.


Stratified Count Aggregator (seroepi.aggregate)

aggregate

Dedicated aggregation engine, parameter validation, and structured Aggregates result container.

Classes:

Name Description
Aggregator

Scikit-learn compatible transformer for dataset aggregation, temporal windowing, and stratification.

Aggregator

Bases: StratifiedResultMixin, BaseEstimator, TransformerMixin

Scikit-learn compatible transformer for dataset aggregation, temporal windowing, and stratification.

Transforms an isolate-level Dataset into a structured contingency table (data_) with columns [*strata_cols, "target", "event", "n"], supporting both mutually exclusive compositional traits (e.g. capsule K-locus serotypes) and binary/compound Target definitions, with optional cluster deduplication (adjust_for), Cartesian zero-padding (pad_zeros), and rolling temporal windows (every).

Parameters:

Name Type Description Default
target Target | Criterion | Variable

Target Variable, Criterion, or composite Target to aggregate.

required
strata Sequence[Variable] | Variable | None

Optional stratification Variable or sequence of Variable instances (e.g. Country, Year).

None
adjust_for Variable | None

Optional cluster/outbreak Variable; when provided, event and n count unique clusters rather than raw isolate rows.

None
negative_indicator str | Pattern[str] | Sequence[str] | None

Optional string, regex pattern, or sequence of strings treated as missing/negative calls for compositional targets.

None
pad_zeros bool

Whether to zero-pad unobserved (stratum, target) cells via Cartesian product grid. Recommended (pad_zeros=True) prior to fitting hierarchical Bayesian models.

False
filter_qc bool

Whether to automatically apply all QC variables registered on the input Dataset.

True
every str | None

Optional Polars duration string (e.g. "1y", "6mo") for dynamic temporal windowing.

None
period str | None

Optional Polars window length string when using sliding temporal windows (defaults to every).

None
offset str | None

Optional Polars window offset string for dynamic temporal grouping.

None
closed Literal['left', 'right', 'none', 'both']

Boundary closure rule ("left", "right", "none", or "both") for temporal windows.

'left'
upsample bool

Whether to upsample missing regular temporal intervals with zero counts.

False
compositional bool | None

Optional explicit override for whether target is treated as compositional.

None

Attributes:

Name Type Description
data_ DataFrame

Fitted Polars DataFrame containing [*strata_cols, "target", "event", "n"].

variables_ dict[str, Variable]

Mapping of output column names to semantic Variable instances.

target_ Variable

Resolved output Trait variable representing the "target" column.

strata_ tuple[Variable, ...]

Tuple of stratification Variable instances.

adjust_for_ Variable | None

Optional cluster-adjustment Variable.

is_compositional_ bool

Whether data_ represents mutually exclusive compositional categories.

is_padded_ bool

Whether unobserved cells were zero-padded or upsampled.

Examples:

>>> from seroepi.aggregate import Aggregator
>>> from seroepi.examples import KlebnetNeonatalSepsis
>>> ds = KlebnetNeonatalSepsis.load()
>>> agg = Aggregator(
...     target=ds.var("K_locus"),
...     strata=ds.var("Country"),
...     pad_zeros=True,
... ).fit(ds)
>>> agg.data_.columns
['Country', 'target', 'event', 'n']

Methods:

Name Description
fit_transform

Fits the aggregator on X and returns the fitted Aggregator instance.

transform

Clones and fits the aggregator on X without mutating self in-place.

fit

Executes QC filtering, target resolution, strata projection, windowing, and zero-padding on X.

Source code in src/seroepi/aggregate.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
class Aggregator(StratifiedResultMixin, BaseEstimator, TransformerMixin):
    """Scikit-learn compatible transformer for dataset aggregation, temporal windowing, and stratification.

    Transforms an isolate-level `Dataset` into a structured contingency table (`data_`) with columns
    `[*strata_cols, "target", "event", "n"]`, supporting both mutually exclusive compositional traits
    (e.g. capsule K-locus serotypes) and binary/compound `Target` definitions, with optional cluster
    deduplication (`adjust_for`), Cartesian zero-padding (`pad_zeros`), and rolling temporal windows (`every`).

    Args:
        target: Target `Variable`, `Criterion`, or composite `Target` to aggregate.
        strata: Optional stratification `Variable` or sequence of `Variable` instances (e.g. `Country`, `Year`).
        adjust_for: Optional cluster/outbreak `Variable`; when provided, `event` and `n` count unique clusters
            rather than raw isolate rows.
        negative_indicator: Optional string, regex pattern, or sequence of strings treated as missing/negative
            calls for compositional targets.
        pad_zeros: Whether to zero-pad unobserved `(stratum, target)` cells via Cartesian product grid.
            Recommended (`pad_zeros=True`) prior to fitting hierarchical Bayesian models.
        filter_qc: Whether to automatically apply all `QC` variables registered on the input `Dataset`.
        every: Optional Polars duration string (e.g. `"1y"`, `"6mo"`) for dynamic temporal windowing.
        period: Optional Polars window length string when using sliding temporal windows (defaults to `every`).
        offset: Optional Polars window offset string for dynamic temporal grouping.
        closed: Boundary closure rule (`"left"`, `"right"`, `"none"`, or `"both"`) for temporal windows.
        upsample: Whether to upsample missing regular temporal intervals with zero counts.
        compositional: Optional explicit override for whether `target` is treated as compositional.

    Attributes:
        data_: Fitted Polars `DataFrame` containing `[*strata_cols, "target", "event", "n"]`.
        variables_: Mapping of output column names to semantic `Variable` instances.
        target_: Resolved output `Trait` variable representing the `"target"` column.
        strata_: Tuple of stratification `Variable` instances.
        adjust_for_: Optional cluster-adjustment `Variable`.
        is_compositional_: Whether `data_` represents mutually exclusive compositional categories.
        is_padded_: Whether unobserved cells were zero-padded or upsampled.

    Examples:
        >>> from seroepi.aggregate import Aggregator
        >>> from seroepi.examples import KlebnetNeonatalSepsis
        >>> ds = KlebnetNeonatalSepsis.load()
        >>> agg = Aggregator(
        ...     target=ds.var("K_locus"),
        ...     strata=ds.var("Country"),
        ...     pad_zeros=True,
        ... ).fit(ds)
        >>> agg.data_.columns
        ['Country', 'target', 'event', 'n']
    """

    is_fitted_: bool = False
    data_: pl.DataFrame
    variables_: dict[str, Variable]
    target_: Variable
    strata_: tuple[Variable, ...]
    adjust_for_: Variable | None
    is_compositional_: bool
    is_padded_: bool

    def __init__(
        self,
        target: Target | Criterion | Variable,
        strata: Sequence[Variable] | Variable | None = None,
        adjust_for: Variable | None = None,
        negative_indicator: str | re.Pattern[str] | Sequence[str] | None = None,
        pad_zeros: bool = False,
        filter_qc: bool = True,
        every: str | None = None,
        period: str | None = None,
        offset: str | None = None,
        closed: Literal["left", "right", "none", "both"] = "left",
        upsample: bool = False,
        compositional: bool | None = None,
    ) -> None:
        if not isinstance(target, (Target, Criterion, Variable)):
            raise TypeError(
                f"Aggregator target must be a Variable, Criterion, or Target instance, got {type(target).__name__}. "
                "Use dataset.var(...) to retrieve it."
            )
        if adjust_for is not None and not isinstance(adjust_for, Variable):
            raise TypeError(
                f"Aggregator adjust_for must be a Variable instance, got {type(adjust_for).__name__}. "
                "Use dataset.var(...) to retrieve it."
            )
        self.target = target
        self.strata = self._normalize_strata(strata)
        self.adjust_for = adjust_for
        self.negative_indicator = negative_indicator
        self.pad_zeros = pad_zeros
        self.filter_qc = filter_qc
        self.every = every
        self.period = period
        self.offset = offset
        self.closed = closed
        self.upsample = upsample
        self.compositional = compositional

    def _get_frame(self) -> pl.DataFrame:
        return self.data_

    def _set_frame(self, df: pl.DataFrame) -> None:
        self.data_ = df

    @staticmethod
    def _normalize_strata(strata: Sequence[Variable] | Variable | None) -> tuple[Variable, ...]:
        """Normalizes strata parameter into a tuple of strictly Variable items."""
        if strata is None:
            return ()
        if isinstance(strata, Variable):
            return (strata,)
        if isinstance(strata, (list, tuple, Sequence)):
            for idx, s in enumerate(strata):
                if not isinstance(s, Variable):
                    raise TypeError(
                        f"Strata item at index {idx} must be a Variable instance, got {type(s).__name__}. "
                        "Use dataset.var(...) to retrieve it."
                    )
            return tuple(strata)
        raise TypeError(f"strata must be a Variable or Sequence of Variables, got {type(strata).__name__}.")

    def fit_transform(self, X: Dataset, y: Any = None, **fit_params: Any) -> Self:
        """Fits the aggregator on `X` and returns the fitted `Aggregator` instance.

        Args:
            X: Input isolate-level `Dataset`.
            y: Ignored; present for scikit-learn pipeline compatibility.
            **fit_params: Additional fit keyword parameters (unused).

        Returns:
            Fitted `self`.
        """
        return self.fit(X, y)

    def transform(self, X: Dataset) -> Self:
        """Clones and fits the aggregator on `X` without mutating `self` in-place.

        Args:
            X: Input isolate-level `Dataset` to aggregate using this instance's configuration.

        Returns:
            A newly fitted `Aggregator` clone containing the aggregated `data_` for `X`.

        Raises:
            RuntimeError: If `self` has not been fitted yet.
        """
        self.check_is_fitted()
        return copy.copy(self).fit(X)

    def fit(self, X: Dataset, y: Any = None) -> Self:
        """Executes QC filtering, target resolution, strata projection, windowing, and zero-padding on `X`.

        Args:
            X: Input isolate-level `Dataset`.
            y: Ignored; present for scikit-learn compatibility.

        Returns:
            Fitted `self` with populated `data_`, `variables_`, `target_`, and `strata_`.

        Raises:
            KeyError: If `adjust_for` or any required variable column is missing from `X`.
        """
        schema = X.data.schema

        # 1. Coerce target into canonical Target and build output target Variable
        resolved_target = Target.coerce(
            self.target,
            compositional=self.compositional,
            negative_indicator=self.negative_indicator,
        )
        is_compositional = resolved_target.is_compositional
        target_var = Trait(column="target", title=resolved_target.title, compositional=is_compositional)

        # 2. Base Frame Preparation: QC Filter, Strata & Temporal Projections, Cluster Resolution
        lf, strata_vars, strata_cols, temporal_var, cluster_col = self._prepare_base_frame(X, schema)

        # 3. Denominator & Event Aggregation
        denom_expr = pl.col(cluster_col).n_unique().alias("n") if cluster_col else pl.len().alias("n")

        valid_lf: pl.LazyFrame | None = None
        strata_n: pl.LazyFrame | None = None
        target_col: str | None = None

        if is_compositional:
            target_col = "target"
            cat_expr = resolved_target.to_category_expr().alias("target")
            lf = lf.with_columns(cat_expr)
            valid_lf = lf.filter(pl.col("target").is_not_null())
            comp_target_expr = pl.col("target")

            event_expr = pl.col(cluster_col).n_unique().alias("event") if cluster_col else pl.len().alias("event")

            # Stratum denominators (computed over all QC-passed samples in the stratum/window)
            if self.every and temporal_var:
                by_cols = [c for c in strata_cols if c != temporal_var.column]
                strata_n = (
                    lf.sort(temporal_var.column)
                    .group_by_dynamic(
                        index_column=temporal_var.column,
                        every=self.every,
                        period=self.period or self.every,
                        offset=self.offset,
                        closed=self.closed,
                        group_by=by_cols if by_cols else None,
                    )
                    .agg(denom_expr)
                )
                comp_by_cols = [c for c in strata_cols if c != temporal_var.column] + ["target"]
                events_lf = (
                    valid_lf.with_columns(comp_target_expr)
                    .sort(temporal_var.column)
                    .group_by_dynamic(
                        index_column=temporal_var.column,
                        every=self.every,
                        period=self.period or self.every,
                        offset=self.offset,
                        closed=self.closed,
                        group_by=comp_by_cols,
                    )
                    .agg(event_expr)
                )
            else:
                strata_n = lf.group_by(strata_cols).agg(denom_expr) if strata_cols else lf.select(denom_expr)
                group_keys = strata_cols + [comp_target_expr]
                events_lf = valid_lf.group_by(group_keys).agg(event_expr)

            # Join component events with stratum denominators
            if strata_cols:
                agg_lf = events_lf.join(strata_n, on=strata_cols, how="left")
            else:
                agg_lf = events_lf.join(strata_n, how="cross")

            tname = None
        else:
            tname = resolved_target.name
            lf = lf.with_columns(resolved_target.to_expr())
            event_expr = (
                pl.col(cluster_col).filter(pl.col(tname) > 0).n_unique().alias("event")
                if cluster_col
                else pl.col(tname).cast(pl.Float64).sum().alias("event")
            )

            if self.every and temporal_var:
                by_cols = [c for c in strata_cols if c != temporal_var.column]
                agg_lf = (
                    lf.sort(temporal_var.column)
                    .group_by_dynamic(
                        index_column=temporal_var.column,
                        every=self.every,
                        period=self.period or self.every,
                        offset=self.offset,
                        closed=self.closed,
                        group_by=by_cols if by_cols else None,
                    )
                    .agg([event_expr, denom_expr])
                    .with_columns(target=pl.lit(tname))
                )
            else:
                base_agg = (
                    lf.group_by(strata_cols).agg([event_expr, denom_expr])
                    if strata_cols
                    else lf.select([event_expr, denom_expr])
                )
                agg_lf = base_agg.with_columns(target=pl.lit(tname))

        # 4. Apply Zero-Padding or Temporal Upsampling
        agg_lf = self._apply_padding(
            agg_lf=agg_lf,
            lf=lf,
            strata=strata_vars,
            temporal_var=temporal_var,
            target_name=tname,
            is_compositional=is_compositional,
            target_col=target_col,
            valid_lf=valid_lf,
            strata_n=strata_n,
        )

        agg_lf = agg_lf.select(strata_cols + ["target", "event", "n"])

        # 5. Build Output Variables Mapping & Store Fitted Attributes
        out_vars = self._build_variables_map(strata_vars, target_var)
        self.data_ = agg_lf.collect()
        self.variables_ = out_vars
        self.target_ = target_var
        self.strata_ = tuple(strata_vars)
        self.adjust_for_ = self.adjust_for
        self.is_compositional_ = is_compositional
        self.is_padded_ = bool(self.pad_zeros or self.upsample)
        self.is_fitted_ = True
        return self

    def _prepare_base_frame(
        self, dataset: Dataset, schema: Any
    ) -> tuple[pl.LazyFrame, list[Variable], list[str], Temporal | None, str | None]:
        """Applies QC filters, resolves and projects strata/temporal columns, and validates clustering."""
        lf = dataset.data.lazy()

        # 1. Apply Quality Control filters
        if self.filter_qc:
            qc_expr = [v.filter_expr() for v in dataset.variables.values() if isinstance(v, QC)]
            if qc_expr:
                lf = lf.filter(*qc_expr)

        # 2. Project strata polymorphically
        strata_vars = list(self._normalize_strata(self.strata))
        strata_cols = [s.column for s in strata_vars]
        temporal_var = next((s for s in strata_vars if isinstance(s, Temporal)), None)

        if strata_vars:
            projections = [
                s.parse_expr() if isinstance(s, Temporal) and self.every else s.to_expr() for s in strata_vars
            ]
            lf = lf.with_columns(*projections)

        # 3. Validate cluster column for adjustment
        cluster_col = None
        if self.adjust_for:
            cluster_col = self.adjust_for.column
            if cluster_col not in dataset.data.columns and cluster_col not in schema.names():
                raise KeyError(
                    f"Adjustment variable '{cluster_col}' was not found in dataset columns: {dataset.data.columns}."
                )

        return lf, strata_vars, strata_cols, temporal_var, cluster_col

    def _apply_padding(
        self,
        agg_lf: pl.LazyFrame,
        lf: pl.LazyFrame,
        strata: Sequence[Variable],
        temporal_var: Temporal | None,
        target_name: str | None,
        is_compositional: bool,
        target_col: str | None = None,
        valid_lf: pl.LazyFrame | None = None,
        strata_n: pl.LazyFrame | None = None,
    ) -> pl.LazyFrame:
        """Unified zero-padding and temporal upsampling engine."""
        strata_cols = [s.column for s in strata]
        temporal_interval = self.every or (temporal_var.resolution.interval if temporal_var else None)
        upsampled = False

        # Temporal upsample: completes missing regular time-steps within observed groups
        if (self.upsample or (self.pad_zeros and temporal_var)) and temporal_var and temporal_interval:
            df_tmp = agg_lf.collect()
            if len(df_tmp) > 0 and temporal_var.column in df_tmp.columns:
                time_dtype = df_tmp[temporal_var.column].dtype
                if time_dtype in (pl.Date, pl.Datetime):
                    by_cols = [c for c in strata_cols if c != temporal_var.column]
                    if is_compositional and "target" in df_tmp.columns:
                        by_cols = by_cols + ["target"]
                    df_tmp = (
                        df_tmp.sort(temporal_var.column)
                        .upsample(
                            time_column=temporal_var.column,
                            every=temporal_interval,
                            group_by=by_cols if by_cols else None,
                        )
                        .with_columns(
                            pl.col("event").fill_null(0),
                            pl.col("n").fill_null(0),
                            pl.col("target").fill_null(target_name) if not is_compositional else pl.col("target"),
                            *(pl.col(c).forward_fill() for c in by_cols),
                        )
                    )
                    agg_lf = df_tmp.lazy()
                    upsampled = True

        # Cartesian product grid padding: fills unobserved strata (and target components)
        if not upsampled and self.pad_zeros and strata_cols:
            unique_strata = [lf.select(pl.col(s).drop_nulls().unique()).collect()[s].to_list() for s in strata_cols]
            if is_compositional and target_col and valid_lf is not None:
                unique_targets = (
                    valid_lf.select(pl.col(target_col).cast(pl.Utf8).alias("target").drop_nulls().unique())
                    .collect()["target"]
                    .to_list()
                )
                all_dims = unique_strata + [unique_targets]
                all_cols = strata_cols + ["target"]
                grid_rows = list(itertools.product(*all_dims))
                grid_df = pl.DataFrame(grid_rows, schema=all_cols, orient="row").lazy()

                assert strata_n is not None
                agg_lf = (
                    grid_df.join(agg_lf.drop("n"), on=strata_cols + ["target"], how="left")
                    .join(strata_n, on=strata_cols, how="left")
                    .with_columns(
                        pl.col("event").fill_null(0),
                        pl.col("n").fill_null(0),
                    )
                )
            else:
                grid_rows = list(itertools.product(*unique_strata))
                grid_df = pl.DataFrame(grid_rows, schema=strata_cols, orient="row").lazy()
                agg_lf = grid_df.join(agg_lf, on=strata_cols, how="left").with_columns(
                    pl.col("event").fill_null(0),
                    pl.col("n").fill_null(0),
                    pl.col("target").fill_null(target_name),
                )

        return agg_lf

    @staticmethod
    def _build_variables_map(
        strata_vars: list[Variable],
        target_var: Variable,
    ) -> dict[str, Variable]:
        """Constructs the output variable mapping including strata, target, and metrics."""
        out_vars: dict[str, Variable] = {}
        for s in strata_vars:
            out_vars[s.column] = s
        out_vars["target"] = target_var
        out_vars["event"] = Count("event", is_denominator=False)
        out_vars["n"] = Count("n", is_denominator=True)
        return out_vars

fit_transform

fit_transform(
    X: Dataset, y: Any = None, **fit_params: Any
) -> Self

Fits the aggregator on X and returns the fitted Aggregator instance.

Parameters:

Name Type Description Default
X Dataset

Input isolate-level Dataset.

required
y Any

Ignored; present for scikit-learn pipeline compatibility.

None
**fit_params Any

Additional fit keyword parameters (unused).

{}

Returns:

Type Description
Self

Fitted self.

Source code in src/seroepi/aggregate.py
def fit_transform(self, X: Dataset, y: Any = None, **fit_params: Any) -> Self:
    """Fits the aggregator on `X` and returns the fitted `Aggregator` instance.

    Args:
        X: Input isolate-level `Dataset`.
        y: Ignored; present for scikit-learn pipeline compatibility.
        **fit_params: Additional fit keyword parameters (unused).

    Returns:
        Fitted `self`.
    """
    return self.fit(X, y)

transform

transform(X: Dataset) -> Self

Clones and fits the aggregator on X without mutating self in-place.

Parameters:

Name Type Description Default
X Dataset

Input isolate-level Dataset to aggregate using this instance's configuration.

required

Returns:

Type Description
Self

A newly fitted Aggregator clone containing the aggregated data_ for X.

Raises:

Type Description
RuntimeError

If self has not been fitted yet.

Source code in src/seroepi/aggregate.py
def transform(self, X: Dataset) -> Self:
    """Clones and fits the aggregator on `X` without mutating `self` in-place.

    Args:
        X: Input isolate-level `Dataset` to aggregate using this instance's configuration.

    Returns:
        A newly fitted `Aggregator` clone containing the aggregated `data_` for `X`.

    Raises:
        RuntimeError: If `self` has not been fitted yet.
    """
    self.check_is_fitted()
    return copy.copy(self).fit(X)

fit

fit(X: Dataset, y: Any = None) -> Self

Executes QC filtering, target resolution, strata projection, windowing, and zero-padding on X.

Parameters:

Name Type Description Default
X Dataset

Input isolate-level Dataset.

required
y Any

Ignored; present for scikit-learn compatibility.

None

Returns:

Type Description
Self

Fitted self with populated data_, variables_, target_, and strata_.

Raises:

Type Description
KeyError

If adjust_for or any required variable column is missing from X.

Source code in src/seroepi/aggregate.py
def fit(self, X: Dataset, y: Any = None) -> Self:
    """Executes QC filtering, target resolution, strata projection, windowing, and zero-padding on `X`.

    Args:
        X: Input isolate-level `Dataset`.
        y: Ignored; present for scikit-learn compatibility.

    Returns:
        Fitted `self` with populated `data_`, `variables_`, `target_`, and `strata_`.

    Raises:
        KeyError: If `adjust_for` or any required variable column is missing from `X`.
    """
    schema = X.data.schema

    # 1. Coerce target into canonical Target and build output target Variable
    resolved_target = Target.coerce(
        self.target,
        compositional=self.compositional,
        negative_indicator=self.negative_indicator,
    )
    is_compositional = resolved_target.is_compositional
    target_var = Trait(column="target", title=resolved_target.title, compositional=is_compositional)

    # 2. Base Frame Preparation: QC Filter, Strata & Temporal Projections, Cluster Resolution
    lf, strata_vars, strata_cols, temporal_var, cluster_col = self._prepare_base_frame(X, schema)

    # 3. Denominator & Event Aggregation
    denom_expr = pl.col(cluster_col).n_unique().alias("n") if cluster_col else pl.len().alias("n")

    valid_lf: pl.LazyFrame | None = None
    strata_n: pl.LazyFrame | None = None
    target_col: str | None = None

    if is_compositional:
        target_col = "target"
        cat_expr = resolved_target.to_category_expr().alias("target")
        lf = lf.with_columns(cat_expr)
        valid_lf = lf.filter(pl.col("target").is_not_null())
        comp_target_expr = pl.col("target")

        event_expr = pl.col(cluster_col).n_unique().alias("event") if cluster_col else pl.len().alias("event")

        # Stratum denominators (computed over all QC-passed samples in the stratum/window)
        if self.every and temporal_var:
            by_cols = [c for c in strata_cols if c != temporal_var.column]
            strata_n = (
                lf.sort(temporal_var.column)
                .group_by_dynamic(
                    index_column=temporal_var.column,
                    every=self.every,
                    period=self.period or self.every,
                    offset=self.offset,
                    closed=self.closed,
                    group_by=by_cols if by_cols else None,
                )
                .agg(denom_expr)
            )
            comp_by_cols = [c for c in strata_cols if c != temporal_var.column] + ["target"]
            events_lf = (
                valid_lf.with_columns(comp_target_expr)
                .sort(temporal_var.column)
                .group_by_dynamic(
                    index_column=temporal_var.column,
                    every=self.every,
                    period=self.period or self.every,
                    offset=self.offset,
                    closed=self.closed,
                    group_by=comp_by_cols,
                )
                .agg(event_expr)
            )
        else:
            strata_n = lf.group_by(strata_cols).agg(denom_expr) if strata_cols else lf.select(denom_expr)
            group_keys = strata_cols + [comp_target_expr]
            events_lf = valid_lf.group_by(group_keys).agg(event_expr)

        # Join component events with stratum denominators
        if strata_cols:
            agg_lf = events_lf.join(strata_n, on=strata_cols, how="left")
        else:
            agg_lf = events_lf.join(strata_n, how="cross")

        tname = None
    else:
        tname = resolved_target.name
        lf = lf.with_columns(resolved_target.to_expr())
        event_expr = (
            pl.col(cluster_col).filter(pl.col(tname) > 0).n_unique().alias("event")
            if cluster_col
            else pl.col(tname).cast(pl.Float64).sum().alias("event")
        )

        if self.every and temporal_var:
            by_cols = [c for c in strata_cols if c != temporal_var.column]
            agg_lf = (
                lf.sort(temporal_var.column)
                .group_by_dynamic(
                    index_column=temporal_var.column,
                    every=self.every,
                    period=self.period or self.every,
                    offset=self.offset,
                    closed=self.closed,
                    group_by=by_cols if by_cols else None,
                )
                .agg([event_expr, denom_expr])
                .with_columns(target=pl.lit(tname))
            )
        else:
            base_agg = (
                lf.group_by(strata_cols).agg([event_expr, denom_expr])
                if strata_cols
                else lf.select([event_expr, denom_expr])
            )
            agg_lf = base_agg.with_columns(target=pl.lit(tname))

    # 4. Apply Zero-Padding or Temporal Upsampling
    agg_lf = self._apply_padding(
        agg_lf=agg_lf,
        lf=lf,
        strata=strata_vars,
        temporal_var=temporal_var,
        target_name=tname,
        is_compositional=is_compositional,
        target_col=target_col,
        valid_lf=valid_lf,
        strata_n=strata_n,
    )

    agg_lf = agg_lf.select(strata_cols + ["target", "event", "n"])

    # 5. Build Output Variables Mapping & Store Fitted Attributes
    out_vars = self._build_variables_map(strata_vars, target_var)
    self.data_ = agg_lf.collect()
    self.variables_ = out_vars
    self.target_ = target_var
    self.strata_ = tuple(strata_vars)
    self.adjust_for_ = self.adjust_for
    self.is_compositional_ = is_compositional
    self.is_padded_ = bool(self.pad_zeros or self.upsample)
    self.is_fitted_ = True
    return self

Estimator Base Contracts (seroepi.estimators.base)

base

Universal base classes, adapters, and contracts for SeroEpi estimators.

Classes:

Name Description
BaseEstimator

Abstract base class for all SeroEpi prevalence estimators.

BayesianEstimator

Abstract base class for hierarchical Bayesian prevalence estimators.

BaseEstimator

Bases: StratifiedResultMixin, RegressorMixin, BaseEstimator, ABC

Abstract base class for all SeroEpi prevalence estimators.

Implements scikit-learn's BaseEstimator and RegressorMixin interfaces alongside StratifiedResultMixin, providing uniform fitted tabular storage (estimates_), posterior/bootstrap draw pooling (target_posterior_draws()), stratum pooling (summarize_by_target(), pool_strata()), stratum tail-lumping (summarize_by_stratum(), lump_strata()), and within-stratum cumulative coverage trajectories (cumulative_coverage()).

Attributes:

Name Type Description
is_fitted_ bool

Whether fit() has been executed on this estimator.

estimates_ DataFrame | None

Fitted Polars DataFrame containing [*strata_cols, "target", "event", "n", "estimate", "lower", "upper"].

variables_ dict[str, Variable]

Mapping of output column names to semantic Variable instances.

target_ Variable

Fitted target Variable.

strata_ tuple[Variable, ...]

Tuple of stratification Variable instances.

adjust_for_ Variable | None

Optional cluster-adjustment Variable.

is_compositional_ bool

Whether target categories are mutually exclusive compositional categories.

is_padded_ bool

Whether unobserved (stratum, target) cells were zero-padded.

aggregator_ Any

Reference to the upstream fitted Aggregator passed to fit().

train_draws_ ndarray | None

Optional (n_draws, n_obs) matrix of posterior or bootstrap prevalence draws.

warnings_ tuple[str, ...]

Tuple of deduplicated user-facing warning strings recorded during fit().

model_family str

High-level estimator family label (e.g. 'Unpooled', 'Hierarchical Bayesian').

engine str | None

Optional inference backend label (e.g. 'SVI', 'MCMC').

name str | None

Optional user-assigned instance display name.

is_bayesian bool

Whether this estimator produces Bayesian credible intervals (CrI).

ci_prefix str

Short uncertainty interval prefix ('CI' or 'CrI').

alpha float

Significance level for (1 - alpha) uncertainty intervals (defaults to 0.05 for 95% intervals).

Methods:

Name Description
describe_interval

Descriptive string for uncertainty intervals (e.g. '95% CIs').

ranked_targets

Returns target categories ranked descending by mean estimated prevalence across strata.

target_posterior_draws

Returns pooled posterior or bootstrap prevalence draws per target if supported by this estimator.

summarize_by_target

Aggregates prevalence estimates and uncertainty intervals across all strata for each target category.

pool_strata

Returns an unstratified clone with estimates pooled across all strata per target.

summarize_by_stratum

Computes total prevalence across targets within each single stratum using true stratum denominators.

lump_strata

Retains the top x_limit - 1 strata and pools remaining strata into other_label per target.

cumulative_coverage

Computes cumulative target coverage and model-faithful uncertainty intervals within each stratum.

contrast_label_with

Infers comparative display labels contrasting self with other.

fit

Fits the estimator to data.

predict

Standard scikit-learn predict returning point estimate arrays or uncertainty intervals.

predict_interval

Explicit method to obtain (estimate, lower, upper) uncertainty intervals.

fit_predict

Fits the model and returns the fitted estimates DataFrame.

score

Calculates negative weighted Brier score between observed events and model predictions.

filter

Filters the fitted estimator's estimates_ and underlying aggregator_ by predicates or kwargs.

Source code in src/seroepi/estimators/base.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
class BaseEstimator(StratifiedResultMixin, RegressorMixin, SklearnBaseEstimator, ABC):
    """Abstract base class for all SeroEpi prevalence estimators.

    Implements scikit-learn's `BaseEstimator` and `RegressorMixin` interfaces alongside
    `StratifiedResultMixin`, providing uniform fitted tabular storage (`estimates_`),
    posterior/bootstrap draw pooling (`target_posterior_draws()`), stratum pooling
    (`summarize_by_target()`, `pool_strata()`), stratum tail-lumping (`summarize_by_stratum()`,
    `lump_strata()`), and within-stratum cumulative coverage trajectories (`cumulative_coverage()`).

    Attributes:
        is_fitted_: Whether `fit()` has been executed on this estimator.
        estimates_: Fitted Polars `DataFrame` containing `[*strata_cols, "target", "event", "n", "estimate", "lower", "upper"]`.
        variables_: Mapping of output column names to semantic `Variable` instances.
        target_: Fitted target `Variable`.
        strata_: Tuple of stratification `Variable` instances.
        adjust_for_: Optional cluster-adjustment `Variable`.
        is_compositional_: Whether target categories are mutually exclusive compositional categories.
        is_padded_: Whether unobserved `(stratum, target)` cells were zero-padded.
        aggregator_: Reference to the upstream fitted `Aggregator` passed to `fit()`.
        train_draws_: Optional `(n_draws, n_obs)` matrix of posterior or bootstrap prevalence draws.
        warnings_: Tuple of deduplicated user-facing warning strings recorded during `fit()`.
        model_family: High-level estimator family label (e.g. `'Unpooled'`, `'Hierarchical Bayesian'`).
        engine: Optional inference backend label (e.g. `'SVI'`, `'MCMC'`).
        name: Optional user-assigned instance display name.
        is_bayesian: Whether this estimator produces Bayesian credible intervals (`CrI`).
        ci_prefix: Short uncertainty interval prefix (`'CI'` or `'CrI'`).
        alpha: Significance level for `(1 - alpha)` uncertainty intervals (defaults to `0.05` for 95% intervals).
    """

    is_fitted_: bool = False
    estimates_: pl.DataFrame | None = None
    variables_: dict[str, Variable] = {}
    target_: Variable
    strata_: tuple[Variable, ...] = ()
    adjust_for_: Variable | None = None
    is_compositional_: bool = False
    is_padded_: bool = False
    aggregator_: Any = None
    train_draws_: np.ndarray | None = None
    warnings_: tuple[str, ...] = ()

    model_family: str = "Estimator"
    engine: str | None = None
    name: str | None = None
    is_bayesian: bool = False
    ci_prefix: str = "CI"
    alpha: float = 0.05

    def _get_frame(self) -> pl.DataFrame:
        assert self.estimates_ is not None
        return self.estimates_

    def _set_frame(self, df: pl.DataFrame) -> None:
        self.estimates_ = df

    def _record_warning(
        self,
        message: str,
        category: type[Warning] = UserWarning,
        *,
        stacklevel: int = 3,
    ) -> None:
        """Records `message` on `self.warnings_` (deduplicated) and emits a Python warning."""
        clean = str(message).strip()
        if clean and clean not in self.warnings_:
            self.warnings_ = (*self.warnings_, clean)
        warnings.warn(clean, category, stacklevel=stacklevel)

    def _bind_aggregator(self, X: Aggregator | Any) -> pl.DataFrame:
        """Validates a fitted `Aggregator` or `TransmissionEstimator` input, binds its metadata to `self`, and returns `X.data_`."""
        if not isinstance(X, StratifiedResultMixin) or not hasattr(X, "data_"):
            raise TypeError(f"Expected Aggregator instance, got {type(X).__name__}")
        X.check_is_fitted()

        df = X.data_
        if "event" not in df.columns or "n" not in df.columns:
            raise ValueError("Aggregator data must contain 'event' and 'n' columns.")

        self.aggregator_ = X
        self.strata_ = X.strata_
        self.target_ = X.target_
        self.adjust_for_ = X.adjust_for_
        self.is_compositional_ = X.is_compositional_
        self.is_padded_ = X.is_padded_
        self.warnings_ = ()
        return df

    def _finalize_fit(
        self,
        df: pl.DataFrame,
        p: np.ndarray,
        lower: np.ndarray,
        upper: np.ndarray,
        *,
        method: str | None,
    ) -> Self:
        """Attaches `estimate`, `lower`, and `upper` columns, registers the `Estimate` variable, and marks fitted."""
        assert self.aggregator_ is not None
        self.estimates_ = df.with_columns(
            estimate=pl.Series(np.nan_to_num(np.asarray(p, dtype=np.float64), nan=0.0)),
            lower=pl.Series(np.nan_to_num(np.asarray(lower, dtype=np.float64), nan=0.0)),
            upper=pl.Series(np.nan_to_num(np.asarray(upper, dtype=np.float64), nan=0.0)),
        )
        out_vars = dict(self.aggregator_.variables_)
        out_vars["estimate"] = Estimate(
            column="estimate",
            lower="lower",
            upper="upper",
            level=1.0 - self.alpha,
            method=method,
            bounded=True,
            ci_prefix=self.ci_prefix,
            format=".1%",
            interval_description=self.describe_interval(),
        )
        self.variables_ = out_vars
        self.is_fitted_ = True
        return self

    def _predict_training_fastpath(
        self,
        X: Aggregator | Any,
        return_interval: bool,
    ) -> np.ndarray | tuple[np.ndarray, np.ndarray, np.ndarray] | None:
        """Validates `predict(X)` arguments and returns training estimates directly when `X is self.aggregator_`."""
        self.check_is_fitted()
        if not isinstance(X, StratifiedResultMixin) or not hasattr(X, "data_"):
            raise TypeError(f"Expected Aggregator instance, got {type(X).__name__}")
        assert self.estimates_ is not None
        if X is self.aggregator_:
            est = self.estimates_["estimate"].to_numpy()
            if return_interval:
                return est, self.estimates_["lower"].to_numpy(), self.estimates_["upper"].to_numpy()
            return est
        return None

    def _aggregate_stratum_counts(self, df: pl.DataFrame) -> pl.DataFrame:
        """Aggregates total events (capped at stratum denominator `n`) per single stratum."""
        stratum_col = self.single_stratum_col
        return (
            df.group_by(stratum_col, maintain_order=True)
            .agg(
                pl.col("event").sum().alias("_raw_event"),
                pl.col("n").max().alias("n"),
            )
            .with_columns(
                event=pl.min_horizontal(pl.col("_raw_event"), pl.col("n")),
            )
            .drop("_raw_event")
        )

    @staticmethod
    def _summarize_draws(
        draws: np.ndarray,
        alpha: float = 0.05,
    ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
        """Computes mean and equal-tailed `(1 - alpha)` bounds across axis 0 of a `(n_draws, n_obs)` matrix."""
        mean_p = np.mean(draws, axis=0)
        lower_p, upper_p = np.percentile(
            draws,
            [100.0 * (alpha / 2.0), 100.0 * (1.0 - alpha / 2.0)],
            axis=0,
        )
        return mean_p, lower_p, upper_p

    def _cumulative_draws_by_stratum(self, df_sorted: pl.DataFrame, draws: np.ndarray) -> np.ndarray:
        """Accumulates and clips `(n_draws, len(df_sorted))` prevalence draws within each single stratum."""
        stratum_vals = df_sorted[self.single_stratum_col].cast(pl.String).to_numpy()
        cum_draws = np.zeros_like(draws)
        for s_val in dict.fromkeys(stratum_vals):
            mask = np.flatnonzero(stratum_vals == s_val)
            cum_draws[:, mask] = np.clip(np.cumsum(draws[:, mask], axis=1), 0.0, 1.0)
        return cum_draws

    def _row_draws_for_frame(self, df: pl.DataFrame) -> np.ndarray | None:
        """Returns `(n_draws, len(df))` row-level prevalence draws if the estimator supports draw simulation."""
        return None

    @property
    def ci_plural(self) -> str:
        """Plural representation of the uncertainty interval (e.g. 'CIs' or 'CrIs')."""
        return f"{self.ci_prefix}s"

    @property
    def ci_percentage(self) -> int:
        """Percentage integer of the uncertainty interval (e.g. 95)."""
        return int(round((1.0 - self.alpha) * 100))

    @property
    def display_name(self) -> str:
        """Formatted human-readable display name for the model/method."""
        if self.name:
            return self.name
        if self.engine:
            return f"{self.model_family} ({self.engine})"
        return self.model_family

    def describe_interval(self) -> str:
        """Descriptive string for uncertainty intervals (e.g. '95% CIs')."""
        return f"{self.ci_percentage}% {self.ci_plural}"

    @property
    def estimate(self) -> Estimate:
        """Returns the fitted Estimate variable definition."""
        self.check_is_fitted()
        est_var = self.variables_.get("estimate")
        if est_var is not None and isinstance(est_var, Estimate):
            return est_var
        return Estimate(
            column="estimate",
            lower="lower" if self.estimates_ is not None and "lower" in self.estimates_.columns else None,
            upper="upper" if self.estimates_ is not None and "upper" in self.estimates_.columns else None,
            ci_prefix=self.ci_prefix,
            interval_description=self.describe_interval(),
        )

    def ranked_targets(self, top_n: int | None = None) -> list[str]:
        """Returns target categories ranked descending by mean estimated prevalence across strata.

        Args:
            top_n: Optional maximum number of top-ranked targets to return.

        Returns:
            List of target category strings ordered from highest to lowest mean prevalence.
        """
        self.check_is_fitted()
        assert self.estimates_ is not None

        target_col = self.target_col
        est_col = self.estimate.column

        summary = (
            self.estimates_.lazy()
            .group_by(target_col)
            .agg(pl.col(est_col).mean().alias("_prev"))
            .sort("_prev", descending=True)
            .collect()
        )

        ranked: list[str] = [str(x) for x in summary[target_col].to_list()]
        return ranked[:top_n] if (top_n is not None and top_n > 0) else ranked

    def _require_single_stratum_frame(self, targets: Sequence[str] | None = None) -> pl.DataFrame:
        """Validates fitted single-stratum state and returns optionally target-filtered estimates."""
        self.check_is_fitted()
        if self.estimates_ is None:
            raise ValueError("Estimator estimates_ DataFrame is None.")
        if not self.is_single_stratum:
            if self.n_strata == 0:
                raise ValueError(
                    "Single-stratum aggregation requires an estimator fitted with exactly 1 stratum (got 0 strata)."
                )
            raise ValueError(
                f"Single-stratum aggregation requires an estimator fitted with exactly 1 stratum, "
                f"got {self.n_strata}: {list(self.strata_cols)}."
            )
        if targets is not None:
            return self.estimates_.filter(pl.col(self.target_col).is_in([str(t) for t in targets]))
        return self.estimates_

    @abstractmethod
    def _pool_strata_by_target(
        self,
        df: pl.DataFrame,
        *,
        weighting: Literal["pooled", "equal"] = "pooled",
    ) -> pl.DataFrame:
        """Pools rows across strata for each target and computes model-specific uncertainty intervals."""
        ...

    @abstractmethod
    def _summarize_strata_and_lump(
        self,
        df: pl.DataFrame,
        x_limit: int | None,
        other_label: str,
    ) -> tuple[pl.DataFrame, list[str]]:
        """Computes stratum-level total prevalence across targets and optionally bins tail strata into 'Other'."""
        ...

    @abstractmethod
    def _compute_cumulative_coverage_columns(self, df_sorted: pl.DataFrame) -> pl.DataFrame:
        """Attaches cum_estimate, cum_lower, and cum_upper columns to a stratum-ordered DataFrame."""
        ...

    @staticmethod
    def _pool_draws_by_target(
        df: pl.DataFrame,
        p_draws: np.ndarray,
        target_col: str,
        weighting: Literal["pooled", "equal"] = "pooled",
    ) -> tuple[pl.DataFrame, list[str], np.ndarray]:
        """Pools a `(n_draws, len(df))` draw matrix across strata per target using pooled or equal weights."""
        agg_df = df.group_by(target_col, maintain_order=True).agg(
            pl.col("event").sum().alias("event"),
            pl.col("n").sum().alias("n"),
        )
        target_vals = df[target_col].cast(pl.String).to_numpy()
        n_vals = df["n"].to_numpy().astype(float)
        targets_order = agg_df[target_col].cast(pl.String).to_list()

        pooled_draws = np.zeros((p_draws.shape[0], len(targets_order)), dtype=float)
        for idx, t_val in enumerate(targets_order):
            mask = target_vals == t_val
            if weighting == "equal":
                pooled_draws[:, idx] = np.mean(p_draws[:, mask], axis=1)
            else:
                w = n_vals[mask]
                w_sum = float(np.sum(w))
                if w_sum > 0.0:
                    pooled_draws[:, idx] = p_draws[:, mask] @ (w / w_sum)
                else:
                    pooled_draws[:, idx] = np.mean(p_draws[:, mask], axis=1)
        return agg_df, targets_order, pooled_draws

    def target_posterior_draws(
        self,
        targets: Sequence[str] | None = None,
        *,
        weighting: Literal["pooled", "equal"] = "pooled",
    ) -> tuple[list[str], np.ndarray] | None:
        """Returns pooled posterior or bootstrap prevalence draws per target if supported by this estimator.

        Args:
            targets: Optional subset of target names to include.
            weighting: Stratum weighting scheme (`'pooled'` for sample-size weighted, `'equal'` for macro-averaged).

        Returns:
            Tuple `(target_names, draws_matrix)` of shape `(n_draws, len(target_names))`, or `None`
            for analytical frequentist estimators without draw simulation.
        """
        self.check_is_fitted()
        assert self.estimates_ is not None
        df = (
            self.estimates_.filter(pl.col(self.target_col).is_in([str(t) for t in targets]))
            if targets is not None
            else self.estimates_
        )
        p_draws = self._row_draws_for_frame(df)
        if p_draws is None:
            return None
        if df.is_empty():
            return [], np.zeros((p_draws.shape[0], 0), dtype=float)
        _, targets_order, pooled_draws = self._pool_draws_by_target(
            df,
            p_draws,
            self.target_col,
            weighting=weighting,
        )
        return targets_order, pooled_draws

    def summarize_by_target(
        self,
        targets: Sequence[str] | None = None,
        *,
        weighting: Literal["pooled", "equal"] = "pooled",
    ) -> pl.DataFrame:
        """Aggregates prevalence estimates and uncertainty intervals across all strata for each target category.

        Args:
            targets: Optional sequence of target categories to include.
            weighting: Stratum aggregation scheme (`'pooled'` for sample-size weighted or `'equal'` for unweighted macro-average).

        Returns:
            Polars `DataFrame` with columns `[target_col, "event", "n", "estimate", "lower", "upper"]`
            sorted descending by estimated prevalence.

        Raises:
            ValueError: If `weighting` is invalid or `estimates_` is `None`.
        """
        if weighting not in ("pooled", "equal"):
            raise ValueError(f"weighting must be 'pooled' or 'equal', got {weighting!r}.")
        self.check_is_fitted()
        if self.estimates_ is None:
            raise ValueError("Estimator estimates_ DataFrame is None.")
        df = (
            self.estimates_.filter(pl.col(self.target_col).is_in([str(t) for t in targets]))
            if targets is not None
            else self.estimates_
        )
        est_col = self.estimate.column
        if df.is_empty():
            return pl.DataFrame(
                schema={
                    self.target_col: pl.String,
                    "event": pl.Int64,
                    "n": pl.Int64,
                    est_col: pl.Float64,
                    "lower": pl.Float64,
                    "upper": pl.Float64,
                }
            )
        if self.n_strata == 0:
            return df.select([self.target_col, "event", "n", est_col, "lower", "upper"]).sort(
                [est_col, self.target_col], descending=[True, False]
            )
        return self._pool_strata_by_target(df, weighting=weighting).sort(
            [est_col, self.target_col], descending=[True, False]
        )

    def pool_strata(
        self,
        *,
        weighting: Literal["pooled", "equal"] = "pooled",
    ) -> Self:
        """Returns an unstratified clone with estimates pooled across all strata per target.

        Args:
            weighting: Stratum weighting scheme (`'pooled'` or `'equal'`).

        Returns:
            Shallow clone of `self` with `strata_ = ()` and `estimates_` set to `summarize_by_target(weighting=weighting)`.
        """
        self.check_is_fitted()
        if self.n_strata == 0:
            return self
        cloned = copy.copy(self)
        cloned.estimates_ = self.summarize_by_target(weighting=weighting)
        cloned.strata_ = ()
        return cloned

    def summarize_by_stratum(
        self,
        targets: Sequence[str] | None = None,
        *,
        x_limit: int | None = None,
        other_label: str = "Other",
    ) -> tuple[pl.DataFrame, list[str]]:
        """Computes total prevalence across targets within each single stratum using true stratum denominators.

        Ranks strata descending by total prevalence and optionally pools the tail strata beyond
        `x_limit - 1` into `other_label`.

        Args:
            targets: Optional subset of target categories to sum over within each stratum.
            x_limit: Optional maximum number of strata to display (must be `>= 2` when specified).
            other_label: Label used for the pooled tail stratum when `x_limit` is exceeded.

        Returns:
            Tuple `(stratum_df, ordered_strata)` where `stratum_df` contains stratum-level counts,
            point estimates, and uncertainty intervals, and `ordered_strata` lists the stratum keys in rank order.

        Raises:
            ValueError: If `x_limit < 2` or the estimator was not fitted with exactly 1 stratum.
        """
        if x_limit is not None and x_limit < 2:
            raise ValueError(f"x_limit must be >= 2, got {x_limit}.")
        df = self._require_single_stratum_frame(targets)
        if df.is_empty():
            empty = pl.DataFrame(
                schema={
                    self.single_stratum_col: pl.String,
                    "event": pl.Int64,
                    "n": pl.Int64,
                    self.estimate.column: pl.Float64,
                    "lower": pl.Float64,
                    "upper": pl.Float64,
                }
            )
            return empty, []
        return self._summarize_strata_and_lump(df, x_limit, other_label)

    def lump_strata(
        self,
        targets: Sequence[str] | None = None,
        *,
        x_limit: int | None = None,
        ranked_strata: Sequence[str] | None = None,
        other_label: str = "Other",
    ) -> tuple[pl.DataFrame, list[str]]:
        """Retains the top `x_limit - 1` strata and pools remaining strata into `other_label` per target.

        Args:
            targets: Optional subset of target categories to retain.
            x_limit: Optional maximum number of strata columns (`>= 2`).
            ranked_strata: Optional explicit stratum ordering overriding prevalence-based ranking.
            other_label: Display label for the pooled tail stratum.

        Returns:
            Tuple `(lumped_df, ordered_strata)` with model-faithful uncertainty intervals on the pooled `'Other'` rows.

        Raises:
            ValueError: If `x_limit < 2` or the estimator is not single-stratum.
        """
        if x_limit is not None and x_limit < 2:
            raise ValueError(f"x_limit must be >= 2, got {x_limit}.")
        df = self._require_single_stratum_frame(targets)
        stratum_col = self.single_stratum_col

        if ranked_strata is None:
            _, sorted_x = self._summarize_strata_and_lump(df, None, other_label)
        else:
            sorted_x = [str(s) for s in ranked_strata]

        if x_limit is None or len(sorted_x) <= x_limit:
            return df, sorted_x

        top_x = sorted_x[: x_limit - 1]
        other_x = sorted_x[x_limit - 1 :]

        kept_df = df.filter(pl.col(stratum_col).cast(pl.String).is_in(top_x))
        binned_df = df.filter(pl.col(stratum_col).cast(pl.String).is_in(other_x))

        other_df = self._pool_strata_by_target(binned_df).with_columns(
            pl.lit(other_label).alias(stratum_col),
        )
        combined_df = pl.concat([kept_df, other_df], how="diagonal_relaxed")
        return combined_df, [*top_x, other_label]

    def cumulative_coverage(
        self,
        targets: Sequence[str] | None = None,
        *,
        top_n: int | None = None,
        max_strata: int | None = None,
        other_label: str = "Other",
    ) -> tuple[pl.DataFrame, list[str]]:
        """Computes cumulative target coverage and model-faithful uncertainty intervals within each stratum.

        Completes missing `(stratum, target)` steps via within-stratum forward-filling so unpadded
        aggregators produce continuous step-by-step trajectories, and optionally caps the number of
        displayed strata to `max_strata` by pooling tail strata into `other_label`.

        Args:
            targets: Optional explicit ordered sequence of target categories to accumulate across.
            top_n: Optional cap on the number of top-ranked targets when `targets` is `None`.
            max_strata: Optional cap on the number of individual strata before pooling the tail into `other_label`.
            other_label: Display label for the pooled tail stratum.

        Returns:
            Tuple `(cum_df, ordered_targets)` where `cum_df` includes `cum_event`, `cum_estimate`,
            `cum_lower`, and `cum_upper` columns.
        """
        import itertools

        eff_targets = [str(t) for t in targets] if targets is not None else self.ranked_targets(top_n)
        df_filtered = self._require_single_stratum_frame(eff_targets)
        target_col = self.target_col
        stratum_col = self.single_stratum_col

        target_rank = pl.DataFrame(
            {
                target_col: eff_targets,
                "target_rank": list(range(len(eff_targets))),
            }
        )
        df_sorted = df_filtered.join(target_rank, on=target_col, how="left").sort([stratum_col, "target_rank"])

        df_sorted = (
            df_sorted.with_columns(
                pl.col("event").cum_sum().over(stratum_col).alias("_raw_cum_event"),
                pl.col("n").max().over(stratum_col).alias("max_n"),
            )
            .with_columns(
                cum_event=pl.min_horizontal(pl.col("_raw_cum_event"), pl.col("max_n")),
            )
            .drop("_raw_cum_event")
        )

        df_cum = self._compute_cumulative_coverage_columns(df_sorted)

        # Complete full (stratum x target) grid so unpadded aggregators have continuous trajectories
        strata_stats = (
            df_cum.group_by(stratum_col, maintain_order=True)
            .agg(
                pl.col("max_n").max().alias("max_n"),
                pl.col("cum_estimate").max().alias("_final_cov"),
            )
            .sort(["max_n", "_final_cov", stratum_col], descending=[True, True, False])
        )
        strata_order = [str(s) for s in strata_stats[stratum_col].to_list()]

        if len(df_cum) < len(strata_order) * len(eff_targets) and strata_order and eff_targets:
            grid_rows = list(itertools.product(strata_order, range(len(eff_targets))))
            grid_df = pl.DataFrame(
                {
                    stratum_col: [r[0] for r in grid_rows],
                    "target_rank": [r[1] for r in grid_rows],
                    target_col: [eff_targets[r[1]] for r in grid_rows],
                }
            ).join(strata_stats.select([stratum_col, "max_n"]), on=stratum_col, how="left")

            df_cum = (
                grid_df.join(
                    df_cum.drop(["max_n", target_col]),
                    on=[stratum_col, "target_rank"],
                    how="left",
                )
                .sort([stratum_col, "target_rank"])
                .with_columns(
                    pl.col("event").fill_null(0),
                    pl.col("n").fill_null(pl.col("max_n")),
                    pl.col("cum_event").forward_fill().over(stratum_col).fill_null(0),
                    pl.col("cum_estimate").forward_fill().over(stratum_col).fill_null(0.0),
                    pl.col("cum_lower").forward_fill().over(stratum_col).fill_null(0.0),
                    pl.col("cum_upper").forward_fill().over(stratum_col).fill_null(0.0),
                )
            )

        if max_strata is not None and max_strata >= 2 and len(strata_order) > max_strata:
            kept_strata = strata_order[: max_strata - 1]
            kept_df = df_cum.filter(pl.col(stratum_col).cast(pl.String).is_in(kept_strata))
            tail_df = df_cum.filter(~pl.col(stratum_col).cast(pl.String).is_in(kept_strata))

            other_df = (
                tail_df.group_by(["target_rank", target_col], maintain_order=True)
                .agg(
                    pl.lit(other_label).alias(stratum_col),
                    pl.col("event").sum().alias("event"),
                    pl.col("max_n").sum().alias("n"),
                    pl.col("max_n").sum().alias("max_n"),
                    pl.col("cum_event").sum().alias("cum_event"),
                    (
                        (pl.col("cum_estimate") * pl.col("max_n")).sum()
                        / pl.when(pl.col("max_n").sum() > 0).then(pl.col("max_n").sum()).otherwise(1.0)
                    ).alias("cum_estimate"),
                    (
                        (pl.col("cum_lower") * pl.col("max_n")).sum()
                        / pl.when(pl.col("max_n").sum() > 0).then(pl.col("max_n").sum()).otherwise(1.0)
                    ).alias("cum_lower"),
                    (
                        (pl.col("cum_upper") * pl.col("max_n")).sum()
                        / pl.when(pl.col("max_n").sum() > 0).then(pl.col("max_n").sum()).otherwise(1.0)
                    ).alias("cum_upper"),
                )
                .sort("target_rank")
            )
            if hasattr(self, "_compute_ci") and not getattr(self, "is_bootstrapped", False):
                k_o = other_df["cum_event"].to_numpy().astype(float)
                n_o = other_df["max_n"].to_numpy().astype(float)
                p_o, lo_o, hi_o = self._compute_ci(k_o, n_o)  # ty: ignore[call-non-callable]
                other_df = other_df.with_columns(
                    cum_estimate=pl.Series(np.nan_to_num(p_o, nan=0.0)),
                    cum_lower=pl.Series(np.nan_to_num(lo_o, nan=0.0)),
                    cum_upper=pl.Series(np.nan_to_num(hi_o, nan=0.0)),
                )

            df_cum = pl.concat([kept_df, other_df], how="diagonal_relaxed")

        return df_cum, eff_targets

    def contrast_label_with(
        self,
        other: "BaseEstimator",
        self_label: str | None = None,
        other_label: str | None = None,
    ) -> tuple[str, str]:
        """Infers comparative display labels contrasting `self` with `other`.

        Args:
            other: Second fitted `BaseEstimator` being contrasted against `self`.
            self_label: Optional explicit label override for `self`.
            other_label: Optional explicit label override for `other`.

        Returns:
            Tuple `(resolved_self_label, resolved_other_label)` distinguishing the two estimators
            by stratum value, cluster-adjustment state, or model display name.
        """
        if self_label is not None and other_label is not None:
            return self_label, other_label

        same_model = self.display_name == other.display_name
        self_adj = self.adjust_for_title
        other_adj = other.adjust_for_title
        same_adj = self_adj == other_adj

        self_stratum = self.infer_single_stratum_value()
        other_stratum = other.infer_single_stratum_value()
        diff_stratum = self_stratum is not None and other_stratum is not None and self_stratum != other_stratum

        if diff_stratum and same_model and same_adj:
            inferred_self = self_stratum
            inferred_other = other_stratum
        elif same_model and not same_adj:
            inferred_self = f"Adjusted ({self_adj})" if self_adj else "Unadjusted"
            inferred_other = f"Adjusted ({other_adj})" if other_adj else "Unadjusted"
        elif not same_model and same_adj:
            if self_adj:
                inferred_self = f"Adjusted ({self_adj}) {self.display_name}"
                inferred_other = f"Adjusted ({other_adj}) {other.display_name}"
            else:
                inferred_self = self.display_name
                inferred_other = other.display_name
        else:
            inferred_self = f"Adjusted ({self_adj}) {self.display_name}" if self_adj else self.display_name
            inferred_other = f"Adjusted ({other_adj}) {other.display_name}" if other_adj else other.display_name

        return self_label or inferred_self, other_label or inferred_other

    @abstractmethod
    def fit(self, X: Aggregator, y: Any = None, sample_weight: Any = None) -> Self:
        """Fits the estimator to data.

        Args:
            X: Input fitted Aggregator instance.
            y: Target values (optional if already embedded in X).
            sample_weight: Optional observation weights.

        Returns:
            Fitted estimator instance.
        """
        ...

    @abstractmethod
    def predict(self, X: Aggregator, return_interval: bool = False) -> np.ndarray | tuple[np.ndarray, ...]:
        """Standard scikit-learn predict returning point estimate arrays or uncertainty intervals.

        Args:
            X: Fitted Aggregator instance.
            return_interval: If True, returns a 3-tuple (estimate, lower, upper) of 1D arrays.
                Otherwise, returns the 1D point estimate array.

        Returns:
            1D NumPy array or 3-tuple of 1D NumPy arrays.
        """
        ...

    def predict_interval(self, X: Aggregator, alpha: float = 0.05) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
        """Explicit method to obtain (estimate, lower, upper) uncertainty intervals.

        Args:
            X: Fitted Aggregator instance.
            alpha: Significance level (default 0.05 for 95% intervals).

        Returns:
            3-tuple of 1D NumPy arrays (estimate, lower, upper).
        """
        res = self.predict(X, return_interval=True)
        if isinstance(res, tuple) and len(res) == 3:
            return res
        point_est = np.asarray(res, dtype=float)
        return point_est, point_est, point_est

    def fit_predict(self, X: Aggregator, y: Any = None, sample_weight: Any = None) -> pl.DataFrame:
        """Fits the model and returns the fitted estimates DataFrame.

        Args:
            X: Fitted Aggregator instance.
            y: Target values.
            sample_weight: Optional observation weights.

        Returns:
            Fitted estimates Polars DataFrame containing estimate, lower, and upper bounds.
        """
        self.fit(X, y=y, sample_weight=sample_weight)
        assert self.estimates_ is not None
        return self.estimates_

    def score(self, X: Aggregator, y: Any = None, sample_weight: Any = None) -> float:
        """Calculates negative weighted Brier score between observed events and model predictions.

        Higher scores indicate better calibration, with 0.0 being optimal.

        Args:
            X: Fitted Aggregator instance.
            y: Optional ground-truth target values (prevalence proportions).
            sample_weight: Optional sample weights (defaults to cohort size n).

        Returns:
            Negative weighted Brier score in [-1.0, 0.0].
        """
        self.check_is_fitted()
        p_pred = np.clip(np.asarray(self.predict(X), dtype=float), 0.0, 1.0)
        df = X.data_

        if y is not None:
            y_true = np.clip(np.asarray(y, dtype=float), 0.0, 1.0)
            w = np.asarray(sample_weight, dtype=float) if sample_weight is not None else np.ones(len(y_true))
        elif "event" in df.columns and "n" in df.columns:
            k = df["event"].to_numpy().astype(float)
            n = np.maximum(1e-6, df["n"].to_numpy().astype(float))
            y_true = np.clip(k / n, 0.0, 1.0)
            w = n
        elif "estimate" in df.columns:
            y_true = np.clip(df["estimate"].to_numpy().astype(float), 0.0, 1.0)
            w = np.ones(len(y_true))
        else:
            return 0.0

        total_w = float(np.sum(w))
        if total_w <= 0:
            return 0.0

        brier = float(np.sum(w * (y_true - p_pred) ** 2) / total_w)
        return -brier

    def filter(self, *predicates: pl.Expr | bool, **kwargs: Any) -> Self:
        """Filters the fitted estimator's `estimates_` and underlying `aggregator_` by predicates or kwargs.

        Args:
            *predicates: Polars boolean filter expressions (`pl.Expr`).
            **kwargs: Column equality constraints (`col_name=value`).

        Returns:
            Shallow clone of `self` with filtered `estimates_` and synchronized `aggregator_`.
        """
        cloned = super().filter(*predicates, **kwargs)
        if self.aggregator_ is not None and self.aggregator_.data_ is not None:
            agg_data_cols = set(self.aggregator_.data_.columns)
            agg_kwargs = {k: v for k, v in kwargs.items() if k in agg_data_cols}
            try:
                if agg_kwargs:
                    cloned.aggregator_ = self.aggregator_.filter(**agg_kwargs)
                elif not kwargs and predicates:
                    cloned.aggregator_ = self.aggregator_.filter(*predicates)
            except Exception:
                pass
        return cloned

ci_plural property

ci_plural: str

Plural representation of the uncertainty interval (e.g. 'CIs' or 'CrIs').

ci_percentage property

ci_percentage: int

Percentage integer of the uncertainty interval (e.g. 95).

display_name property

display_name: str

Formatted human-readable display name for the model/method.

estimate property

estimate: Estimate

Returns the fitted Estimate variable definition.

describe_interval

describe_interval() -> str

Descriptive string for uncertainty intervals (e.g. '95% CIs').

Source code in src/seroepi/estimators/base.py
def describe_interval(self) -> str:
    """Descriptive string for uncertainty intervals (e.g. '95% CIs')."""
    return f"{self.ci_percentage}% {self.ci_plural}"

ranked_targets

ranked_targets(top_n: int | None = None) -> list[str]

Returns target categories ranked descending by mean estimated prevalence across strata.

Parameters:

Name Type Description Default
top_n int | None

Optional maximum number of top-ranked targets to return.

None

Returns:

Type Description
list[str]

List of target category strings ordered from highest to lowest mean prevalence.

Source code in src/seroepi/estimators/base.py
def ranked_targets(self, top_n: int | None = None) -> list[str]:
    """Returns target categories ranked descending by mean estimated prevalence across strata.

    Args:
        top_n: Optional maximum number of top-ranked targets to return.

    Returns:
        List of target category strings ordered from highest to lowest mean prevalence.
    """
    self.check_is_fitted()
    assert self.estimates_ is not None

    target_col = self.target_col
    est_col = self.estimate.column

    summary = (
        self.estimates_.lazy()
        .group_by(target_col)
        .agg(pl.col(est_col).mean().alias("_prev"))
        .sort("_prev", descending=True)
        .collect()
    )

    ranked: list[str] = [str(x) for x in summary[target_col].to_list()]
    return ranked[:top_n] if (top_n is not None and top_n > 0) else ranked

target_posterior_draws

target_posterior_draws(
    targets: Sequence[str] | None = None,
    *,
    weighting: Literal["pooled", "equal"] = "pooled",
) -> tuple[list[str], ndarray] | None

Returns pooled posterior or bootstrap prevalence draws per target if supported by this estimator.

Parameters:

Name Type Description Default
targets Sequence[str] | None

Optional subset of target names to include.

None
weighting Literal['pooled', 'equal']

Stratum weighting scheme ('pooled' for sample-size weighted, 'equal' for macro-averaged).

'pooled'

Returns:

Type Description
tuple[list[str], ndarray] | None

Tuple (target_names, draws_matrix) of shape (n_draws, len(target_names)), or None

tuple[list[str], ndarray] | None

for analytical frequentist estimators without draw simulation.

Source code in src/seroepi/estimators/base.py
def target_posterior_draws(
    self,
    targets: Sequence[str] | None = None,
    *,
    weighting: Literal["pooled", "equal"] = "pooled",
) -> tuple[list[str], np.ndarray] | None:
    """Returns pooled posterior or bootstrap prevalence draws per target if supported by this estimator.

    Args:
        targets: Optional subset of target names to include.
        weighting: Stratum weighting scheme (`'pooled'` for sample-size weighted, `'equal'` for macro-averaged).

    Returns:
        Tuple `(target_names, draws_matrix)` of shape `(n_draws, len(target_names))`, or `None`
        for analytical frequentist estimators without draw simulation.
    """
    self.check_is_fitted()
    assert self.estimates_ is not None
    df = (
        self.estimates_.filter(pl.col(self.target_col).is_in([str(t) for t in targets]))
        if targets is not None
        else self.estimates_
    )
    p_draws = self._row_draws_for_frame(df)
    if p_draws is None:
        return None
    if df.is_empty():
        return [], np.zeros((p_draws.shape[0], 0), dtype=float)
    _, targets_order, pooled_draws = self._pool_draws_by_target(
        df,
        p_draws,
        self.target_col,
        weighting=weighting,
    )
    return targets_order, pooled_draws

summarize_by_target

summarize_by_target(
    targets: Sequence[str] | None = None,
    *,
    weighting: Literal["pooled", "equal"] = "pooled",
) -> DataFrame

Aggregates prevalence estimates and uncertainty intervals across all strata for each target category.

Parameters:

Name Type Description Default
targets Sequence[str] | None

Optional sequence of target categories to include.

None
weighting Literal['pooled', 'equal']

Stratum aggregation scheme ('pooled' for sample-size weighted or 'equal' for unweighted macro-average).

'pooled'

Returns:

Type Description
DataFrame

Polars DataFrame with columns [target_col, "event", "n", "estimate", "lower", "upper"]

DataFrame

sorted descending by estimated prevalence.

Raises:

Type Description
ValueError

If weighting is invalid or estimates_ is None.

Source code in src/seroepi/estimators/base.py
def summarize_by_target(
    self,
    targets: Sequence[str] | None = None,
    *,
    weighting: Literal["pooled", "equal"] = "pooled",
) -> pl.DataFrame:
    """Aggregates prevalence estimates and uncertainty intervals across all strata for each target category.

    Args:
        targets: Optional sequence of target categories to include.
        weighting: Stratum aggregation scheme (`'pooled'` for sample-size weighted or `'equal'` for unweighted macro-average).

    Returns:
        Polars `DataFrame` with columns `[target_col, "event", "n", "estimate", "lower", "upper"]`
        sorted descending by estimated prevalence.

    Raises:
        ValueError: If `weighting` is invalid or `estimates_` is `None`.
    """
    if weighting not in ("pooled", "equal"):
        raise ValueError(f"weighting must be 'pooled' or 'equal', got {weighting!r}.")
    self.check_is_fitted()
    if self.estimates_ is None:
        raise ValueError("Estimator estimates_ DataFrame is None.")
    df = (
        self.estimates_.filter(pl.col(self.target_col).is_in([str(t) for t in targets]))
        if targets is not None
        else self.estimates_
    )
    est_col = self.estimate.column
    if df.is_empty():
        return pl.DataFrame(
            schema={
                self.target_col: pl.String,
                "event": pl.Int64,
                "n": pl.Int64,
                est_col: pl.Float64,
                "lower": pl.Float64,
                "upper": pl.Float64,
            }
        )
    if self.n_strata == 0:
        return df.select([self.target_col, "event", "n", est_col, "lower", "upper"]).sort(
            [est_col, self.target_col], descending=[True, False]
        )
    return self._pool_strata_by_target(df, weighting=weighting).sort(
        [est_col, self.target_col], descending=[True, False]
    )

pool_strata

pool_strata(
    *, weighting: Literal["pooled", "equal"] = "pooled"
) -> Self

Returns an unstratified clone with estimates pooled across all strata per target.

Parameters:

Name Type Description Default
weighting Literal['pooled', 'equal']

Stratum weighting scheme ('pooled' or 'equal').

'pooled'

Returns:

Type Description
Self

Shallow clone of self with strata_ = () and estimates_ set to summarize_by_target(weighting=weighting).

Source code in src/seroepi/estimators/base.py
def pool_strata(
    self,
    *,
    weighting: Literal["pooled", "equal"] = "pooled",
) -> Self:
    """Returns an unstratified clone with estimates pooled across all strata per target.

    Args:
        weighting: Stratum weighting scheme (`'pooled'` or `'equal'`).

    Returns:
        Shallow clone of `self` with `strata_ = ()` and `estimates_` set to `summarize_by_target(weighting=weighting)`.
    """
    self.check_is_fitted()
    if self.n_strata == 0:
        return self
    cloned = copy.copy(self)
    cloned.estimates_ = self.summarize_by_target(weighting=weighting)
    cloned.strata_ = ()
    return cloned

summarize_by_stratum

summarize_by_stratum(
    targets: Sequence[str] | None = None,
    *,
    x_limit: int | None = None,
    other_label: str = "Other",
) -> tuple[DataFrame, list[str]]

Computes total prevalence across targets within each single stratum using true stratum denominators.

Ranks strata descending by total prevalence and optionally pools the tail strata beyond x_limit - 1 into other_label.

Parameters:

Name Type Description Default
targets Sequence[str] | None

Optional subset of target categories to sum over within each stratum.

None
x_limit int | None

Optional maximum number of strata to display (must be >= 2 when specified).

None
other_label str

Label used for the pooled tail stratum when x_limit is exceeded.

'Other'

Returns:

Type Description
DataFrame

Tuple (stratum_df, ordered_strata) where stratum_df contains stratum-level counts,

list[str]

point estimates, and uncertainty intervals, and ordered_strata lists the stratum keys in rank order.

Raises:

Type Description
ValueError

If x_limit < 2 or the estimator was not fitted with exactly 1 stratum.

Source code in src/seroepi/estimators/base.py
def summarize_by_stratum(
    self,
    targets: Sequence[str] | None = None,
    *,
    x_limit: int | None = None,
    other_label: str = "Other",
) -> tuple[pl.DataFrame, list[str]]:
    """Computes total prevalence across targets within each single stratum using true stratum denominators.

    Ranks strata descending by total prevalence and optionally pools the tail strata beyond
    `x_limit - 1` into `other_label`.

    Args:
        targets: Optional subset of target categories to sum over within each stratum.
        x_limit: Optional maximum number of strata to display (must be `>= 2` when specified).
        other_label: Label used for the pooled tail stratum when `x_limit` is exceeded.

    Returns:
        Tuple `(stratum_df, ordered_strata)` where `stratum_df` contains stratum-level counts,
        point estimates, and uncertainty intervals, and `ordered_strata` lists the stratum keys in rank order.

    Raises:
        ValueError: If `x_limit < 2` or the estimator was not fitted with exactly 1 stratum.
    """
    if x_limit is not None and x_limit < 2:
        raise ValueError(f"x_limit must be >= 2, got {x_limit}.")
    df = self._require_single_stratum_frame(targets)
    if df.is_empty():
        empty = pl.DataFrame(
            schema={
                self.single_stratum_col: pl.String,
                "event": pl.Int64,
                "n": pl.Int64,
                self.estimate.column: pl.Float64,
                "lower": pl.Float64,
                "upper": pl.Float64,
            }
        )
        return empty, []
    return self._summarize_strata_and_lump(df, x_limit, other_label)

lump_strata

lump_strata(
    targets: Sequence[str] | None = None,
    *,
    x_limit: int | None = None,
    ranked_strata: Sequence[str] | None = None,
    other_label: str = "Other",
) -> tuple[DataFrame, list[str]]

Retains the top x_limit - 1 strata and pools remaining strata into other_label per target.

Parameters:

Name Type Description Default
targets Sequence[str] | None

Optional subset of target categories to retain.

None
x_limit int | None

Optional maximum number of strata columns (>= 2).

None
ranked_strata Sequence[str] | None

Optional explicit stratum ordering overriding prevalence-based ranking.

None
other_label str

Display label for the pooled tail stratum.

'Other'

Returns:

Type Description
tuple[DataFrame, list[str]]

Tuple (lumped_df, ordered_strata) with model-faithful uncertainty intervals on the pooled 'Other' rows.

Raises:

Type Description
ValueError

If x_limit < 2 or the estimator is not single-stratum.

Source code in src/seroepi/estimators/base.py
def lump_strata(
    self,
    targets: Sequence[str] | None = None,
    *,
    x_limit: int | None = None,
    ranked_strata: Sequence[str] | None = None,
    other_label: str = "Other",
) -> tuple[pl.DataFrame, list[str]]:
    """Retains the top `x_limit - 1` strata and pools remaining strata into `other_label` per target.

    Args:
        targets: Optional subset of target categories to retain.
        x_limit: Optional maximum number of strata columns (`>= 2`).
        ranked_strata: Optional explicit stratum ordering overriding prevalence-based ranking.
        other_label: Display label for the pooled tail stratum.

    Returns:
        Tuple `(lumped_df, ordered_strata)` with model-faithful uncertainty intervals on the pooled `'Other'` rows.

    Raises:
        ValueError: If `x_limit < 2` or the estimator is not single-stratum.
    """
    if x_limit is not None and x_limit < 2:
        raise ValueError(f"x_limit must be >= 2, got {x_limit}.")
    df = self._require_single_stratum_frame(targets)
    stratum_col = self.single_stratum_col

    if ranked_strata is None:
        _, sorted_x = self._summarize_strata_and_lump(df, None, other_label)
    else:
        sorted_x = [str(s) for s in ranked_strata]

    if x_limit is None or len(sorted_x) <= x_limit:
        return df, sorted_x

    top_x = sorted_x[: x_limit - 1]
    other_x = sorted_x[x_limit - 1 :]

    kept_df = df.filter(pl.col(stratum_col).cast(pl.String).is_in(top_x))
    binned_df = df.filter(pl.col(stratum_col).cast(pl.String).is_in(other_x))

    other_df = self._pool_strata_by_target(binned_df).with_columns(
        pl.lit(other_label).alias(stratum_col),
    )
    combined_df = pl.concat([kept_df, other_df], how="diagonal_relaxed")
    return combined_df, [*top_x, other_label]

cumulative_coverage

cumulative_coverage(
    targets: Sequence[str] | None = None,
    *,
    top_n: int | None = None,
    max_strata: int | None = None,
    other_label: str = "Other",
) -> tuple[DataFrame, list[str]]

Computes cumulative target coverage and model-faithful uncertainty intervals within each stratum.

Completes missing (stratum, target) steps via within-stratum forward-filling so unpadded aggregators produce continuous step-by-step trajectories, and optionally caps the number of displayed strata to max_strata by pooling tail strata into other_label.

Parameters:

Name Type Description Default
targets Sequence[str] | None

Optional explicit ordered sequence of target categories to accumulate across.

None
top_n int | None

Optional cap on the number of top-ranked targets when targets is None.

None
max_strata int | None

Optional cap on the number of individual strata before pooling the tail into other_label.

None
other_label str

Display label for the pooled tail stratum.

'Other'

Returns:

Type Description
DataFrame

Tuple (cum_df, ordered_targets) where cum_df includes cum_event, cum_estimate,

list[str]

cum_lower, and cum_upper columns.

Source code in src/seroepi/estimators/base.py
def cumulative_coverage(
    self,
    targets: Sequence[str] | None = None,
    *,
    top_n: int | None = None,
    max_strata: int | None = None,
    other_label: str = "Other",
) -> tuple[pl.DataFrame, list[str]]:
    """Computes cumulative target coverage and model-faithful uncertainty intervals within each stratum.

    Completes missing `(stratum, target)` steps via within-stratum forward-filling so unpadded
    aggregators produce continuous step-by-step trajectories, and optionally caps the number of
    displayed strata to `max_strata` by pooling tail strata into `other_label`.

    Args:
        targets: Optional explicit ordered sequence of target categories to accumulate across.
        top_n: Optional cap on the number of top-ranked targets when `targets` is `None`.
        max_strata: Optional cap on the number of individual strata before pooling the tail into `other_label`.
        other_label: Display label for the pooled tail stratum.

    Returns:
        Tuple `(cum_df, ordered_targets)` where `cum_df` includes `cum_event`, `cum_estimate`,
        `cum_lower`, and `cum_upper` columns.
    """
    import itertools

    eff_targets = [str(t) for t in targets] if targets is not None else self.ranked_targets(top_n)
    df_filtered = self._require_single_stratum_frame(eff_targets)
    target_col = self.target_col
    stratum_col = self.single_stratum_col

    target_rank = pl.DataFrame(
        {
            target_col: eff_targets,
            "target_rank": list(range(len(eff_targets))),
        }
    )
    df_sorted = df_filtered.join(target_rank, on=target_col, how="left").sort([stratum_col, "target_rank"])

    df_sorted = (
        df_sorted.with_columns(
            pl.col("event").cum_sum().over(stratum_col).alias("_raw_cum_event"),
            pl.col("n").max().over(stratum_col).alias("max_n"),
        )
        .with_columns(
            cum_event=pl.min_horizontal(pl.col("_raw_cum_event"), pl.col("max_n")),
        )
        .drop("_raw_cum_event")
    )

    df_cum = self._compute_cumulative_coverage_columns(df_sorted)

    # Complete full (stratum x target) grid so unpadded aggregators have continuous trajectories
    strata_stats = (
        df_cum.group_by(stratum_col, maintain_order=True)
        .agg(
            pl.col("max_n").max().alias("max_n"),
            pl.col("cum_estimate").max().alias("_final_cov"),
        )
        .sort(["max_n", "_final_cov", stratum_col], descending=[True, True, False])
    )
    strata_order = [str(s) for s in strata_stats[stratum_col].to_list()]

    if len(df_cum) < len(strata_order) * len(eff_targets) and strata_order and eff_targets:
        grid_rows = list(itertools.product(strata_order, range(len(eff_targets))))
        grid_df = pl.DataFrame(
            {
                stratum_col: [r[0] for r in grid_rows],
                "target_rank": [r[1] for r in grid_rows],
                target_col: [eff_targets[r[1]] for r in grid_rows],
            }
        ).join(strata_stats.select([stratum_col, "max_n"]), on=stratum_col, how="left")

        df_cum = (
            grid_df.join(
                df_cum.drop(["max_n", target_col]),
                on=[stratum_col, "target_rank"],
                how="left",
            )
            .sort([stratum_col, "target_rank"])
            .with_columns(
                pl.col("event").fill_null(0),
                pl.col("n").fill_null(pl.col("max_n")),
                pl.col("cum_event").forward_fill().over(stratum_col).fill_null(0),
                pl.col("cum_estimate").forward_fill().over(stratum_col).fill_null(0.0),
                pl.col("cum_lower").forward_fill().over(stratum_col).fill_null(0.0),
                pl.col("cum_upper").forward_fill().over(stratum_col).fill_null(0.0),
            )
        )

    if max_strata is not None and max_strata >= 2 and len(strata_order) > max_strata:
        kept_strata = strata_order[: max_strata - 1]
        kept_df = df_cum.filter(pl.col(stratum_col).cast(pl.String).is_in(kept_strata))
        tail_df = df_cum.filter(~pl.col(stratum_col).cast(pl.String).is_in(kept_strata))

        other_df = (
            tail_df.group_by(["target_rank", target_col], maintain_order=True)
            .agg(
                pl.lit(other_label).alias(stratum_col),
                pl.col("event").sum().alias("event"),
                pl.col("max_n").sum().alias("n"),
                pl.col("max_n").sum().alias("max_n"),
                pl.col("cum_event").sum().alias("cum_event"),
                (
                    (pl.col("cum_estimate") * pl.col("max_n")).sum()
                    / pl.when(pl.col("max_n").sum() > 0).then(pl.col("max_n").sum()).otherwise(1.0)
                ).alias("cum_estimate"),
                (
                    (pl.col("cum_lower") * pl.col("max_n")).sum()
                    / pl.when(pl.col("max_n").sum() > 0).then(pl.col("max_n").sum()).otherwise(1.0)
                ).alias("cum_lower"),
                (
                    (pl.col("cum_upper") * pl.col("max_n")).sum()
                    / pl.when(pl.col("max_n").sum() > 0).then(pl.col("max_n").sum()).otherwise(1.0)
                ).alias("cum_upper"),
            )
            .sort("target_rank")
        )
        if hasattr(self, "_compute_ci") and not getattr(self, "is_bootstrapped", False):
            k_o = other_df["cum_event"].to_numpy().astype(float)
            n_o = other_df["max_n"].to_numpy().astype(float)
            p_o, lo_o, hi_o = self._compute_ci(k_o, n_o)  # ty: ignore[call-non-callable]
            other_df = other_df.with_columns(
                cum_estimate=pl.Series(np.nan_to_num(p_o, nan=0.0)),
                cum_lower=pl.Series(np.nan_to_num(lo_o, nan=0.0)),
                cum_upper=pl.Series(np.nan_to_num(hi_o, nan=0.0)),
            )

        df_cum = pl.concat([kept_df, other_df], how="diagonal_relaxed")

    return df_cum, eff_targets

contrast_label_with

contrast_label_with(
    other: BaseEstimator,
    self_label: str | None = None,
    other_label: str | None = None,
) -> tuple[str, str]

Infers comparative display labels contrasting self with other.

Parameters:

Name Type Description Default
other BaseEstimator

Second fitted BaseEstimator being contrasted against self.

required
self_label str | None

Optional explicit label override for self.

None
other_label str | None

Optional explicit label override for other.

None

Returns:

Type Description
str

Tuple (resolved_self_label, resolved_other_label) distinguishing the two estimators

str

by stratum value, cluster-adjustment state, or model display name.

Source code in src/seroepi/estimators/base.py
def contrast_label_with(
    self,
    other: "BaseEstimator",
    self_label: str | None = None,
    other_label: str | None = None,
) -> tuple[str, str]:
    """Infers comparative display labels contrasting `self` with `other`.

    Args:
        other: Second fitted `BaseEstimator` being contrasted against `self`.
        self_label: Optional explicit label override for `self`.
        other_label: Optional explicit label override for `other`.

    Returns:
        Tuple `(resolved_self_label, resolved_other_label)` distinguishing the two estimators
        by stratum value, cluster-adjustment state, or model display name.
    """
    if self_label is not None and other_label is not None:
        return self_label, other_label

    same_model = self.display_name == other.display_name
    self_adj = self.adjust_for_title
    other_adj = other.adjust_for_title
    same_adj = self_adj == other_adj

    self_stratum = self.infer_single_stratum_value()
    other_stratum = other.infer_single_stratum_value()
    diff_stratum = self_stratum is not None and other_stratum is not None and self_stratum != other_stratum

    if diff_stratum and same_model and same_adj:
        inferred_self = self_stratum
        inferred_other = other_stratum
    elif same_model and not same_adj:
        inferred_self = f"Adjusted ({self_adj})" if self_adj else "Unadjusted"
        inferred_other = f"Adjusted ({other_adj})" if other_adj else "Unadjusted"
    elif not same_model and same_adj:
        if self_adj:
            inferred_self = f"Adjusted ({self_adj}) {self.display_name}"
            inferred_other = f"Adjusted ({other_adj}) {other.display_name}"
        else:
            inferred_self = self.display_name
            inferred_other = other.display_name
    else:
        inferred_self = f"Adjusted ({self_adj}) {self.display_name}" if self_adj else self.display_name
        inferred_other = f"Adjusted ({other_adj}) {other.display_name}" if other_adj else other.display_name

    return self_label or inferred_self, other_label or inferred_other

fit abstractmethod

fit(
    X: Aggregator, y: Any = None, sample_weight: Any = None
) -> Self

Fits the estimator to data.

Parameters:

Name Type Description Default
X Aggregator

Input fitted Aggregator instance.

required
y Any

Target values (optional if already embedded in X).

None
sample_weight Any

Optional observation weights.

None

Returns:

Type Description
Self

Fitted estimator instance.

Source code in src/seroepi/estimators/base.py
@abstractmethod
def fit(self, X: Aggregator, y: Any = None, sample_weight: Any = None) -> Self:
    """Fits the estimator to data.

    Args:
        X: Input fitted Aggregator instance.
        y: Target values (optional if already embedded in X).
        sample_weight: Optional observation weights.

    Returns:
        Fitted estimator instance.
    """
    ...

predict abstractmethod

predict(
    X: Aggregator, return_interval: bool = False
) -> ndarray | tuple[ndarray, ...]

Standard scikit-learn predict returning point estimate arrays or uncertainty intervals.

Parameters:

Name Type Description Default
X Aggregator

Fitted Aggregator instance.

required
return_interval bool

If True, returns a 3-tuple (estimate, lower, upper) of 1D arrays. Otherwise, returns the 1D point estimate array.

False

Returns:

Type Description
ndarray | tuple[ndarray, ...]

1D NumPy array or 3-tuple of 1D NumPy arrays.

Source code in src/seroepi/estimators/base.py
@abstractmethod
def predict(self, X: Aggregator, return_interval: bool = False) -> np.ndarray | tuple[np.ndarray, ...]:
    """Standard scikit-learn predict returning point estimate arrays or uncertainty intervals.

    Args:
        X: Fitted Aggregator instance.
        return_interval: If True, returns a 3-tuple (estimate, lower, upper) of 1D arrays.
            Otherwise, returns the 1D point estimate array.

    Returns:
        1D NumPy array or 3-tuple of 1D NumPy arrays.
    """
    ...

predict_interval

predict_interval(
    X: Aggregator, alpha: float = 0.05
) -> tuple[ndarray, ndarray, ndarray]

Explicit method to obtain (estimate, lower, upper) uncertainty intervals.

Parameters:

Name Type Description Default
X Aggregator

Fitted Aggregator instance.

required
alpha float

Significance level (default 0.05 for 95% intervals).

0.05

Returns:

Type Description
tuple[ndarray, ndarray, ndarray]

3-tuple of 1D NumPy arrays (estimate, lower, upper).

Source code in src/seroepi/estimators/base.py
def predict_interval(self, X: Aggregator, alpha: float = 0.05) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Explicit method to obtain (estimate, lower, upper) uncertainty intervals.

    Args:
        X: Fitted Aggregator instance.
        alpha: Significance level (default 0.05 for 95% intervals).

    Returns:
        3-tuple of 1D NumPy arrays (estimate, lower, upper).
    """
    res = self.predict(X, return_interval=True)
    if isinstance(res, tuple) and len(res) == 3:
        return res
    point_est = np.asarray(res, dtype=float)
    return point_est, point_est, point_est

fit_predict

fit_predict(
    X: Aggregator, y: Any = None, sample_weight: Any = None
) -> DataFrame

Fits the model and returns the fitted estimates DataFrame.

Parameters:

Name Type Description Default
X Aggregator

Fitted Aggregator instance.

required
y Any

Target values.

None
sample_weight Any

Optional observation weights.

None

Returns:

Type Description
DataFrame

Fitted estimates Polars DataFrame containing estimate, lower, and upper bounds.

Source code in src/seroepi/estimators/base.py
def fit_predict(self, X: Aggregator, y: Any = None, sample_weight: Any = None) -> pl.DataFrame:
    """Fits the model and returns the fitted estimates DataFrame.

    Args:
        X: Fitted Aggregator instance.
        y: Target values.
        sample_weight: Optional observation weights.

    Returns:
        Fitted estimates Polars DataFrame containing estimate, lower, and upper bounds.
    """
    self.fit(X, y=y, sample_weight=sample_weight)
    assert self.estimates_ is not None
    return self.estimates_

score

score(
    X: Aggregator, y: Any = None, sample_weight: Any = None
) -> float

Calculates negative weighted Brier score between observed events and model predictions.

Higher scores indicate better calibration, with 0.0 being optimal.

Parameters:

Name Type Description Default
X Aggregator

Fitted Aggregator instance.

required
y Any

Optional ground-truth target values (prevalence proportions).

None
sample_weight Any

Optional sample weights (defaults to cohort size n).

None

Returns:

Type Description
float

Negative weighted Brier score in [-1.0, 0.0].

Source code in src/seroepi/estimators/base.py
def score(self, X: Aggregator, y: Any = None, sample_weight: Any = None) -> float:
    """Calculates negative weighted Brier score between observed events and model predictions.

    Higher scores indicate better calibration, with 0.0 being optimal.

    Args:
        X: Fitted Aggregator instance.
        y: Optional ground-truth target values (prevalence proportions).
        sample_weight: Optional sample weights (defaults to cohort size n).

    Returns:
        Negative weighted Brier score in [-1.0, 0.0].
    """
    self.check_is_fitted()
    p_pred = np.clip(np.asarray(self.predict(X), dtype=float), 0.0, 1.0)
    df = X.data_

    if y is not None:
        y_true = np.clip(np.asarray(y, dtype=float), 0.0, 1.0)
        w = np.asarray(sample_weight, dtype=float) if sample_weight is not None else np.ones(len(y_true))
    elif "event" in df.columns and "n" in df.columns:
        k = df["event"].to_numpy().astype(float)
        n = np.maximum(1e-6, df["n"].to_numpy().astype(float))
        y_true = np.clip(k / n, 0.0, 1.0)
        w = n
    elif "estimate" in df.columns:
        y_true = np.clip(df["estimate"].to_numpy().astype(float), 0.0, 1.0)
        w = np.ones(len(y_true))
    else:
        return 0.0

    total_w = float(np.sum(w))
    if total_w <= 0:
        return 0.0

    brier = float(np.sum(w * (y_true - p_pred) ** 2) / total_w)
    return -brier

filter

filter(*predicates: Expr | bool, **kwargs: Any) -> Self

Filters the fitted estimator's estimates_ and underlying aggregator_ by predicates or kwargs.

Parameters:

Name Type Description Default
*predicates Expr | bool

Polars boolean filter expressions (pl.Expr).

()
**kwargs Any

Column equality constraints (col_name=value).

{}

Returns:

Type Description
Self

Shallow clone of self with filtered estimates_ and synchronized aggregator_.

Source code in src/seroepi/estimators/base.py
def filter(self, *predicates: pl.Expr | bool, **kwargs: Any) -> Self:
    """Filters the fitted estimator's `estimates_` and underlying `aggregator_` by predicates or kwargs.

    Args:
        *predicates: Polars boolean filter expressions (`pl.Expr`).
        **kwargs: Column equality constraints (`col_name=value`).

    Returns:
        Shallow clone of `self` with filtered `estimates_` and synchronized `aggregator_`.
    """
    cloned = super().filter(*predicates, **kwargs)
    if self.aggregator_ is not None and self.aggregator_.data_ is not None:
        agg_data_cols = set(self.aggregator_.data_.columns)
        agg_kwargs = {k: v for k, v in kwargs.items() if k in agg_data_cols}
        try:
            if agg_kwargs:
                cloned.aggregator_ = self.aggregator_.filter(**agg_kwargs)
            elif not kwargs and predicates:
                cloned.aggregator_ = self.aggregator_.filter(*predicates)
        except Exception:
            pass
    return cloned

BayesianEstimator

Bases: BaseEstimator, ABC

Abstract base class for hierarchical Bayesian prevalence estimators.

Configures is_bayesian = True and ci_prefix = 'CrI' so downstream tables and plotters automatically label intervals as Bayesian credible intervals.

Attributes:

Name Type Description
model_family str

Defaults to 'Hierarchical Bayesian'.

engine str | None

Inference engine identifier ('SVI' or 'MCMC').

is_bayesian bool

Always True.

ci_prefix str

Defaults to 'CrI'.

Methods:

Name Description
describe_interval

Descriptive string for Bayesian credible intervals.

Source code in src/seroepi/estimators/base.py
class BayesianEstimator(BaseEstimator, ABC):
    """Abstract base class for hierarchical Bayesian prevalence estimators.

    Configures `is_bayesian = True` and `ci_prefix = 'CrI'` so downstream tables and plotters
    automatically label intervals as Bayesian credible intervals.

    Attributes:
        model_family: Defaults to `'Hierarchical Bayesian'`.
        engine: Inference engine identifier (`'SVI'` or `'MCMC'`).
        is_bayesian: Always `True`.
        ci_prefix: Defaults to `'CrI'`.
    """

    model_family: str = "Hierarchical Bayesian"
    engine: str | None = None
    is_bayesian: bool = True
    ci_prefix: str = "CrI"

    def describe_interval(self) -> str:
        """Descriptive string for Bayesian credible intervals."""
        engine_str = f" {self.engine}" if self.engine else ""
        return f"{self.ci_percentage}%{engine_str} credible intervals"

describe_interval

describe_interval() -> str

Descriptive string for Bayesian credible intervals.

Source code in src/seroepi/estimators/base.py
def describe_interval(self) -> str:
    """Descriptive string for Bayesian credible intervals."""
    engine_str = f" {self.engine}" if self.engine else ""
    return f"{self.ci_percentage}%{engine_str} credible intervals"

Confidence & Credible Interval Kernels (seroepi.estimators.intervals)

intervals

Vectorized analytical, bootstrap, and Dirichlet Bayesian bootstrap confidence/credible interval functions.

Functions:

Name Description
wilson_interval

Computes vectorized Wilson score binomial confidence intervals.

clopper_pearson_interval

Computes vectorized exact Clopper-Pearson binomial confidence intervals via the Beta distribution.

agresti_coull_interval

Computes vectorized Agresti-Coull binomial confidence intervals.

wald_interval

Computes vectorized asymptotic Wald binomial confidence intervals.

jeffreys_interval

Computes vectorized Jeffreys equal-tailed credible intervals with a Beta(0.5, 0.5) prior.

bootstrap_interval

Computes vectorized parametric binomial simulation bootstrap confidence intervals.

bayesian_bootstrap_row_draws

Draws (n_resamples, len(k)) 2-component Beta/Dirichlet proportions via scipy.stats.dirichlet.rvs.

bayesian_bootstrap_interval

Computes Bayesian bootstrap intervals sampled directly via scipy.stats.dirichlet.rvs.

bayesian_bootstrap_compositional_draws

Draws joint stratum-level simplex proportions (n_resamples, len(df)) via scipy.stats.dirichlet.rvs.

wilson_interval

wilson_interval(
    k: ndarray, n: ndarray, alpha: float
) -> tuple[ndarray, ndarray, ndarray]

Computes vectorized Wilson score binomial confidence intervals.

Parameters:

Name Type Description Default
k ndarray

1D NumPy array of observed event counts.

required
n ndarray

1D NumPy array of total sample denominators.

required
alpha float

Two-sided significance level in (0, 1) (e.g., 0.05 for 95% CIs).

required

Returns:

Type Description
tuple[ndarray, ndarray, ndarray]

A 3-tuple (p_hat, lower, upper) of 1D float64 NumPy arrays clipped to [0, 1].

Source code in src/seroepi/estimators/intervals.py
def wilson_interval(k: np.ndarray, n: np.ndarray, alpha: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Computes vectorized Wilson score binomial confidence intervals.

    Args:
        k: 1D NumPy array of observed event counts.
        n: 1D NumPy array of total sample denominators.
        alpha: Two-sided significance level in ``(0, 1)`` (e.g., ``0.05`` for 95% CIs).

    Returns:
        A 3-tuple ``(p_hat, lower, upper)`` of 1D float64 NumPy arrays clipped to ``[0, 1]``.
    """
    z = float(stats.norm.ppf(1.0 - alpha / 2.0))
    z2 = z * z
    safe_n = np.where(n > 0, n, 1)
    p_hat = np.where(n > 0, k / safe_n, 0.0)
    denom = safe_n + z2
    p_tilde = (k + 0.5 * z2) / denom
    margin = (z / denom) * np.sqrt(safe_n * p_hat * (1.0 - p_hat) + 0.25 * z2)
    lower = np.where(n > 0, np.clip(p_tilde - margin, 0.0, 1.0), 0.0)
    upper = np.where(n > 0, np.clip(p_tilde + margin, 0.0, 1.0), 0.0)
    return p_hat, lower, upper

clopper_pearson_interval

clopper_pearson_interval(
    k: ndarray, n: ndarray, alpha: float
) -> tuple[ndarray, ndarray, ndarray]

Computes vectorized exact Clopper-Pearson binomial confidence intervals via the Beta distribution.

Parameters:

Name Type Description Default
k ndarray

1D NumPy array of observed event counts.

required
n ndarray

1D NumPy array of total sample denominators.

required
alpha float

Two-sided significance level in (0, 1) (e.g., 0.05 for 95% CIs).

required

Returns:

Type Description
tuple[ndarray, ndarray, ndarray]

A 3-tuple (p_hat, lower, upper) of 1D float64 NumPy arrays clipped to [0, 1].

Source code in src/seroepi/estimators/intervals.py
def clopper_pearson_interval(k: np.ndarray, n: np.ndarray, alpha: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Computes vectorized exact Clopper-Pearson binomial confidence intervals via the Beta distribution.

    Args:
        k: 1D NumPy array of observed event counts.
        n: 1D NumPy array of total sample denominators.
        alpha: Two-sided significance level in ``(0, 1)`` (e.g., ``0.05`` for 95% CIs).

    Returns:
        A 3-tuple ``(p_hat, lower, upper)`` of 1D float64 NumPy arrays clipped to ``[0, 1]``.
    """
    safe_n = np.where(n > 0, n, 1)
    p_hat = np.where(n > 0, k / safe_n, 0.0)
    lower = np.zeros_like(p_hat, dtype=float)
    upper = np.ones_like(p_hat, dtype=float)

    mask_k_gt_0 = (k > 0) & (n > 0)
    if np.any(mask_k_gt_0):
        lower[mask_k_gt_0] = stats.beta.ppf(alpha / 2.0, k[mask_k_gt_0], safe_n[mask_k_gt_0] - k[mask_k_gt_0] + 1)

    mask_k_lt_n = (k < safe_n) & (n > 0)
    if np.any(mask_k_lt_n):
        upper[mask_k_lt_n] = stats.beta.ppf(1.0 - alpha / 2.0, k[mask_k_lt_n] + 1, safe_n[mask_k_lt_n] - k[mask_k_lt_n])

    lower = np.where(n > 0, lower, 0.0)
    upper = np.where(n > 0, upper, 0.0)
    return p_hat, lower, upper

agresti_coull_interval

agresti_coull_interval(
    k: ndarray, n: ndarray, alpha: float
) -> tuple[ndarray, ndarray, ndarray]

Computes vectorized Agresti-Coull binomial confidence intervals.

Parameters:

Name Type Description Default
k ndarray

1D NumPy array of observed event counts.

required
n ndarray

1D NumPy array of total sample denominators.

required
alpha float

Two-sided significance level in (0, 1) (e.g., 0.05 for 95% CIs).

required

Returns:

Type Description
tuple[ndarray, ndarray, ndarray]

A 3-tuple (p_hat, lower, upper) of 1D float64 NumPy arrays clipped to [0, 1].

Source code in src/seroepi/estimators/intervals.py
def agresti_coull_interval(k: np.ndarray, n: np.ndarray, alpha: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Computes vectorized Agresti-Coull binomial confidence intervals.

    Args:
        k: 1D NumPy array of observed event counts.
        n: 1D NumPy array of total sample denominators.
        alpha: Two-sided significance level in ``(0, 1)`` (e.g., ``0.05`` for 95% CIs).

    Returns:
        A 3-tuple ``(p_hat, lower, upper)`` of 1D float64 NumPy arrays clipped to ``[0, 1]``.
    """
    z = float(stats.norm.ppf(1.0 - alpha / 2.0))
    z2 = z * z
    safe_n = np.where(n > 0, n, 1)
    n_tilde = safe_n + z2
    k_tilde = k + 0.5 * z2
    p_tilde = k_tilde / n_tilde
    margin = z * np.sqrt(p_tilde * (1.0 - p_tilde) / n_tilde)
    p_hat = np.where(n > 0, k / safe_n, 0.0)
    lower = np.where(n > 0, np.clip(p_tilde - margin, 0.0, 1.0), 0.0)
    upper = np.where(n > 0, np.clip(p_tilde + margin, 0.0, 1.0), 0.0)
    return p_hat, lower, upper

wald_interval

wald_interval(
    k: ndarray, n: ndarray, alpha: float
) -> tuple[ndarray, ndarray, ndarray]

Computes vectorized asymptotic Wald binomial confidence intervals.

Parameters:

Name Type Description Default
k ndarray

1D NumPy array of observed event counts.

required
n ndarray

1D NumPy array of total sample denominators.

required
alpha float

Two-sided significance level in (0, 1) (e.g., 0.05 for 95% CIs).

required

Returns:

Type Description
tuple[ndarray, ndarray, ndarray]

A 3-tuple (p_hat, lower, upper) of 1D float64 NumPy arrays clipped to [0, 1].

Source code in src/seroepi/estimators/intervals.py
def wald_interval(k: np.ndarray, n: np.ndarray, alpha: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Computes vectorized asymptotic Wald binomial confidence intervals.

    Args:
        k: 1D NumPy array of observed event counts.
        n: 1D NumPy array of total sample denominators.
        alpha: Two-sided significance level in ``(0, 1)`` (e.g., ``0.05`` for 95% CIs).

    Returns:
        A 3-tuple ``(p_hat, lower, upper)`` of 1D float64 NumPy arrays clipped to ``[0, 1]``.
    """
    z = float(stats.norm.ppf(1.0 - alpha / 2.0))
    safe_n = np.where(n > 0, n, 1)
    p_hat = np.where(n > 0, k / safe_n, 0.0)
    margin = z * np.sqrt(np.where(n > 0, p_hat * (1.0 - p_hat) / safe_n, 0.0))
    lower = np.where(n > 0, np.clip(p_hat - margin, 0.0, 1.0), 0.0)
    upper = np.where(n > 0, np.clip(p_hat + margin, 0.0, 1.0), 0.0)
    return p_hat, lower, upper

jeffreys_interval

jeffreys_interval(
    k: ndarray, n: ndarray, alpha: float
) -> tuple[ndarray, ndarray, ndarray]

Computes vectorized Jeffreys equal-tailed credible intervals with a Beta(0.5, 0.5) prior.

Parameters:

Name Type Description Default
k ndarray

1D NumPy array of observed event counts.

required
n ndarray

1D NumPy array of total sample denominators.

required
alpha float

Two-sided significance level in (0, 1) (e.g., 0.05 for 95% CrIs).

required

Returns:

Type Description
tuple[ndarray, ndarray, ndarray]

A 3-tuple (p_hat, lower, upper) of 1D float64 NumPy arrays clipped to [0, 1].

Source code in src/seroepi/estimators/intervals.py
def jeffreys_interval(k: np.ndarray, n: np.ndarray, alpha: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Computes vectorized Jeffreys equal-tailed credible intervals with a ``Beta(0.5, 0.5)`` prior.

    Args:
        k: 1D NumPy array of observed event counts.
        n: 1D NumPy array of total sample denominators.
        alpha: Two-sided significance level in ``(0, 1)`` (e.g., ``0.05`` for 95% CrIs).

    Returns:
        A 3-tuple ``(p_hat, lower, upper)`` of 1D float64 NumPy arrays clipped to ``[0, 1]``.
    """
    safe_n = np.where(n > 0, n, 1)
    p_hat = np.where(n > 0, k / safe_n, 0.0)
    lower = np.zeros_like(p_hat, dtype=float)
    upper = np.ones_like(p_hat, dtype=float)

    mask_k_gt_0 = (k > 0) & (n > 0)
    if np.any(mask_k_gt_0):
        lower[mask_k_gt_0] = stats.beta.ppf(
            alpha / 2.0, k[mask_k_gt_0] + 0.5, safe_n[mask_k_gt_0] - k[mask_k_gt_0] + 0.5
        )

    mask_k_lt_n = (k < safe_n) & (n > 0)
    if np.any(mask_k_lt_n):
        upper[mask_k_lt_n] = stats.beta.ppf(
            1.0 - alpha / 2.0, k[mask_k_lt_n] + 0.5, safe_n[mask_k_lt_n] - k[mask_k_lt_n] + 0.5
        )

    lower = np.where(n > 0, lower, 0.0)
    upper = np.where(n > 0, upper, 0.0)
    return p_hat, lower, upper

bootstrap_interval

bootstrap_interval(
    k: ndarray,
    n: ndarray,
    alpha: float,
    n_resamples: int = 1000,
    random_state: Any = None,
) -> tuple[ndarray, ndarray, ndarray]

Computes vectorized parametric binomial simulation bootstrap confidence intervals.

Parameters:

Name Type Description Default
k ndarray

1D NumPy array of observed event counts.

required
n ndarray

1D NumPy array of total sample denominators.

required
alpha float

Two-sided significance level in (0, 1) (e.g., 0.05 for 95% CIs).

required
n_resamples int

Number of bootstrap Monte Carlo resamples to draw per row.

1000
random_state Any

Optional integer seed or NumPy Generator for reproducible simulation.

None

Returns:

Type Description
tuple[ndarray, ndarray, ndarray]

A 3-tuple (p_hat, lower, upper) of 1D float64 NumPy arrays clipped to [0, 1].

Source code in src/seroepi/estimators/intervals.py
def bootstrap_interval(
    k: np.ndarray,
    n: np.ndarray,
    alpha: float,
    n_resamples: int = 1000,
    random_state: Any = None,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Computes vectorized parametric binomial simulation bootstrap confidence intervals.

    Args:
        k: 1D NumPy array of observed event counts.
        n: 1D NumPy array of total sample denominators.
        alpha: Two-sided significance level in ``(0, 1)`` (e.g., ``0.05`` for 95% CIs).
        n_resamples: Number of bootstrap Monte Carlo resamples to draw per row.
        random_state: Optional integer seed or NumPy ``Generator`` for reproducible simulation.

    Returns:
        A 3-tuple ``(p_hat, lower, upper)`` of 1D float64 NumPy arrays clipped to ``[0, 1]``.
    """
    safe_n = np.where(n > 0, n, 1)
    p_hat = np.where(n > 0, k / safe_n, 0.0)
    simulations = np.random.default_rng(random_state).binomial(safe_n.astype(int), p_hat, size=(n_resamples, len(k)))
    sim_p = simulations / safe_n
    lower, upper = np.percentile(sim_p, [100 * (alpha / 2.0), 100 * (1.0 - alpha / 2.0)], axis=0)
    return p_hat, np.where(n > 0, lower, 0.0), np.where(n > 0, upper, 0.0)

bayesian_bootstrap_row_draws

bayesian_bootstrap_row_draws(
    k: ndarray,
    n: ndarray,
    n_resamples: int = 1000,
    prior_concentration: float = 0.5,
    random_state: Any = None,
) -> ndarray

Draws (n_resamples, len(k)) 2-component Beta/Dirichlet proportions via scipy.stats.dirichlet.rvs.

Parameters:

Name Type Description Default
k ndarray

1D NumPy array of observed event counts.

required
n ndarray

1D NumPy array of total sample denominators.

required
n_resamples int

Number of posterior Dirichlet draws per row.

1000
prior_concentration float

Symmetric Dirichlet prior concentration added to positive and negative counts.

0.5
random_state Any

Optional integer seed or NumPy Generator for reproducibility.

None

Returns:

Type Description
ndarray

A 2D float64 NumPy array of shape (n_resamples, len(k)) containing posterior prevalence draws.

Raises:

Type Description
ValueError

If prior_concentration < 0.0.

Source code in src/seroepi/estimators/intervals.py
def bayesian_bootstrap_row_draws(
    k: np.ndarray,
    n: np.ndarray,
    n_resamples: int = 1000,
    prior_concentration: float = 0.5,
    random_state: Any = None,
) -> np.ndarray:
    """Draws ``(n_resamples, len(k))`` 2-component Beta/Dirichlet proportions via ``scipy.stats.dirichlet.rvs``.

    Args:
        k: 1D NumPy array of observed event counts.
        n: 1D NumPy array of total sample denominators.
        n_resamples: Number of posterior Dirichlet draws per row.
        prior_concentration: Symmetric Dirichlet prior concentration added to positive and negative counts.
        random_state: Optional integer seed or NumPy ``Generator`` for reproducibility.

    Returns:
        A 2D float64 NumPy array of shape ``(n_resamples, len(k))`` containing posterior prevalence draws.

    Raises:
        ValueError: If ``prior_concentration < 0.0``.
    """
    if prior_concentration < 0.0:
        raise ValueError(f"prior_concentration must be >= 0.0, got {prior_concentration}.")
    rng = np.random.default_rng(random_state)
    draws = np.zeros((n_resamples, len(k)), dtype=float)
    for idx in range(len(k)):
        n_i = float(n[idx])
        if n_i <= 0.0:
            continue
        k_i = float(np.clip(k[idx], 0.0, n_i))
        a1 = k_i + prior_concentration
        a2 = (n_i - k_i) + prior_concentration
        if a1 <= 0.0:
            draws[:, idx] = 0.0
        elif a2 <= 0.0:
            draws[:, idx] = 1.0
        else:
            draws[:, idx] = stats.dirichlet.rvs([a1, a2], size=n_resamples, random_state=rng)[:, 0]
    return draws

bayesian_bootstrap_interval

bayesian_bootstrap_interval(
    k: ndarray,
    n: ndarray,
    alpha: float,
    n_resamples: int = 1000,
    prior_concentration: float = 0.5,
    random_state: Any = None,
) -> tuple[ndarray, ndarray, ndarray]

Computes Bayesian bootstrap intervals sampled directly via scipy.stats.dirichlet.rvs.

Parameters:

Name Type Description Default
k ndarray

1D NumPy array of observed event counts.

required
n ndarray

1D NumPy array of total sample denominators.

required
alpha float

Two-sided significance level in (0, 1) (e.g., 0.05 for 95% CrIs).

required
n_resamples int

Number of posterior Dirichlet draws per row.

1000
prior_concentration float

Symmetric Dirichlet prior concentration parameter >= 0.0.

0.5
random_state Any

Optional integer seed or NumPy Generator for reproducibility.

None

Returns:

Type Description
tuple[ndarray, ndarray, ndarray]

A 3-tuple (p_hat, lower, upper) of 1D float64 NumPy arrays clipped to [0, 1].

Raises:

Type Description
ValueError

If prior_concentration < 0.0.

Source code in src/seroepi/estimators/intervals.py
def bayesian_bootstrap_interval(
    k: np.ndarray,
    n: np.ndarray,
    alpha: float,
    n_resamples: int = 1000,
    prior_concentration: float = 0.5,
    random_state: Any = None,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Computes Bayesian bootstrap intervals sampled directly via ``scipy.stats.dirichlet.rvs``.

    Args:
        k: 1D NumPy array of observed event counts.
        n: 1D NumPy array of total sample denominators.
        alpha: Two-sided significance level in ``(0, 1)`` (e.g., ``0.05`` for 95% CrIs).
        n_resamples: Number of posterior Dirichlet draws per row.
        prior_concentration: Symmetric Dirichlet prior concentration parameter ``>= 0.0``.
        random_state: Optional integer seed or NumPy ``Generator`` for reproducibility.

    Returns:
        A 3-tuple ``(p_hat, lower, upper)`` of 1D float64 NumPy arrays clipped to ``[0, 1]``.

    Raises:
        ValueError: If ``prior_concentration < 0.0``.
    """
    safe_n = np.where(n > 0, n, 1.0)
    p_hat = np.where(n > 0, k / safe_n, 0.0)
    if len(k) == 0:
        return p_hat, np.zeros_like(p_hat), np.zeros_like(p_hat)
    draws = bayesian_bootstrap_row_draws(
        k,
        n,
        n_resamples=n_resamples,
        prior_concentration=prior_concentration,
        random_state=random_state,
    )
    q_low = 100.0 * (alpha / 2.0)
    q_high = 100.0 * (1.0 - alpha / 2.0)
    lower, upper = np.percentile(draws, [q_low, q_high], axis=0)
    lower = np.where(n > 0, np.clip(lower, 0.0, 1.0), 0.0)
    upper = np.where(n > 0, np.clip(upper, 0.0, 1.0), 0.0)
    return p_hat, lower, upper

bayesian_bootstrap_compositional_draws

bayesian_bootstrap_compositional_draws(
    df: DataFrame,
    strata_cols: tuple[str, ...],
    n_resamples: int = 1000,
    prior_concentration: float = 0.5,
    random_state: Any = None,
) -> ndarray

Draws joint stratum-level simplex proportions (n_resamples, len(df)) via scipy.stats.dirichlet.rvs.

Within each stratum, category event counts k_s and any unobserved remainder max(0, n_s - sum(k_s)) are jointly sampled on the probability simplex so that cumulative target coverage and stratum pooling preserve exact compositional negative covariance.

Parameters:

Name Type Description Default
df DataFrame

Aggregated Polars DataFrame containing "event" and "n" columns.

required
strata_cols tuple[str, ...]

Tuple of stratum column names used to partition rows into independent simplices.

required
n_resamples int

Number of joint Dirichlet simplex draws to simulate per stratum.

1000
prior_concentration float

Symmetric Dirichlet prior concentration added to each category.

0.5
random_state Any

Optional integer seed or NumPy Generator for reproducibility.

None

Returns:

Type Description
ndarray

A 2D float64 NumPy array of shape (n_resamples, len(df)) aligned to the rows of df.

Raises:

Type Description
ValueError

If prior_concentration < 0.0.

Source code in src/seroepi/estimators/intervals.py
def bayesian_bootstrap_compositional_draws(
    df: pl.DataFrame,
    strata_cols: tuple[str, ...],
    n_resamples: int = 1000,
    prior_concentration: float = 0.5,
    random_state: Any = None,
) -> np.ndarray:
    """Draws joint stratum-level simplex proportions ``(n_resamples, len(df))`` via ``scipy.stats.dirichlet.rvs``.

    Within each stratum, category event counts ``k_s`` and any unobserved remainder
    ``max(0, n_s - sum(k_s))`` are jointly sampled on the probability simplex so that
    cumulative target coverage and stratum pooling preserve exact compositional negative
    covariance.

    Args:
        df: Aggregated Polars ``DataFrame`` containing ``"event"`` and ``"n"`` columns.
        strata_cols: Tuple of stratum column names used to partition rows into independent simplices.
        n_resamples: Number of joint Dirichlet simplex draws to simulate per stratum.
        prior_concentration: Symmetric Dirichlet prior concentration added to each category.
        random_state: Optional integer seed or NumPy ``Generator`` for reproducibility.

    Returns:
        A 2D float64 NumPy array of shape ``(n_resamples, len(df))`` aligned to the rows of ``df``.

    Raises:
        ValueError: If ``prior_concentration < 0.0``.
    """
    if prior_concentration < 0.0:
        raise ValueError(f"prior_concentration must be >= 0.0, got {prior_concentration}.")
    n_rows = len(df)
    draws = np.zeros((n_resamples, n_rows), dtype=float)
    if n_rows == 0:
        return draws

    k_arr = df["event"].to_numpy().astype(float)
    n_arr = df["n"].to_numpy().astype(float)
    rng = np.random.default_rng(random_state)

    active_strata = [c for c in strata_cols if c in df.columns]
    if active_strata:
        groups: dict[tuple[Any, ...], list[int]] = {}
        strata_tuples = df.select(active_strata).iter_rows()
        for row_idx, key in enumerate(strata_tuples):
            groups.setdefault(key, []).append(row_idx)
        group_indices = list(groups.values())
    else:
        group_indices = [list(range(n_rows))]

    prog = current_progress()
    n_groups = max(1, len(group_indices))
    for g_idx, idx_list in enumerate(group_indices):
        row_idx = np.asarray(idx_list, dtype=int)
        n_s = float(np.max(n_arr[row_idx])) if len(row_idx) > 0 else 0.0
        if n_s <= 0.0:
            continue
        k_s = np.clip(k_arr[row_idx], 0.0, n_s)
        k_len = len(k_s)
        rem = max(0.0, n_s - float(np.sum(k_s)))

        if rem > 0.0 or k_len == 1:
            alpha_vec = np.append(k_s + prior_concentration, rem + prior_concentration)
        else:
            alpha_vec = k_s + prior_concentration

        pos_mask = alpha_vec > 0.0
        n_pos = int(np.sum(pos_mask))
        if n_pos == 0:
            continue
        full_simplex = np.zeros((n_resamples, len(alpha_vec)), dtype=float)
        if n_pos == 1:
            full_simplex[:, pos_mask] = 1.0
        else:
            full_simplex[:, pos_mask] = stats.dirichlet.rvs(
                alpha_vec[pos_mask],
                size=n_resamples,
                random_state=rng,
            )
        draws[:, row_idx] = full_simplex[:, :k_len]
        if prog.is_active:
            prog.report(
                (g_idx + 1) / n_groups,
                message="Fitting Bayesian Bootstrap",
                detail=f"Simulating Dirichlet simplex draws ({g_idx + 1}/{n_groups} strata)",
            )

    return draws

Unpooled & Bootstrap Estimators (seroepi.estimators.unpooled)

unpooled

Unpooled analytical and non-parametric bootstrap prevalence estimator (UnpooledEstimator).

Classes:

Name Description
UnpooledEstimator

Calculates unpooled prevalence proportions and analytical or bootstrap confidence intervals.

UnpooledEstimator

Bases: BaseEstimator

Calculates unpooled prevalence proportions and analytical or bootstrap confidence intervals.

Supports exact Clopper-Pearson, Wilson Score, Agresti-Coull, Wald, Jeffreys, frequentist binomial simulation bootstrap, and Dirichlet Bayesian bootstrap intervals.

Parameters:

Name Type Description Default
method Literal['wilson', 'clopper_pearson', 'agresti_coull', 'wald', 'jeffreys', 'bootstrap', 'bayesian_bootstrap']

Confidence/credible interval estimation method.

'wilson'
alpha float

Significance level (e.g. 0.05 for 95% intervals).

0.05
n_resamples int

Number of Monte Carlo resamples for bootstrap and Bayesian bootstrap methods.

1000
prior_concentration float

Symmetric Dirichlet prior concentration added to counts for bayesian_bootstrap (default 0.5 Jeffreys reference prior; 0.0 for strict Rubin 1981).

0.5
random_state int | Generator | None

Optional integer seed or NumPy Generator for reproducible resampling.

None
name str | None

Optional custom display name for the estimator.

None

Attributes:

Name Type Description
estimates_ DataFrame | None

Fitted Polars DataFrame containing [*strata_cols, "target", "event", "n", "estimate", "lower", "upper"].

train_draws_ ndarray | None

Optional (n_resamples, n_obs) Dirichlet draw matrix when method="bayesian_bootstrap".

Examples:

>>> from seroepi.aggregate import Aggregator
>>> from seroepi.estimators import UnpooledEstimator
>>> from seroepi.examples import KlebnetNeonatalSepsis
>>> ds = KlebnetNeonatalSepsis.load()
>>> agg = Aggregator(target=ds.var("K_locus"), strata=ds.var("Country")).fit(ds)
>>> est = UnpooledEstimator(method="wilson", alpha=0.05).fit(agg)
>>> est.ranked_targets(top_n=3)
['KL102', 'KL2', 'KL25']

Methods:

Name Description
describe_interval

Descriptive string for uncertainty intervals.

fit

Fits the unpooled prevalence estimator on a fitted Aggregator.

predict

Predicts point prevalence proportions or (estimate, lower, upper) intervals for X.

Source code in src/seroepi/estimators/unpooled.py
class UnpooledEstimator(BaseEstimator):
    """Calculates unpooled prevalence proportions and analytical or bootstrap confidence intervals.

    Supports exact Clopper-Pearson, Wilson Score, Agresti-Coull, Wald, Jeffreys,
    frequentist binomial simulation bootstrap, and Dirichlet Bayesian bootstrap intervals.

    Args:
        method: Confidence/credible interval estimation method.
        alpha: Significance level (e.g. 0.05 for 95% intervals).
        n_resamples: Number of Monte Carlo resamples for bootstrap and Bayesian bootstrap methods.
        prior_concentration: Symmetric Dirichlet prior concentration added to counts for
            `bayesian_bootstrap` (default 0.5 Jeffreys reference prior; 0.0 for strict Rubin 1981).
        random_state: Optional integer seed or NumPy Generator for reproducible resampling.
        name: Optional custom display name for the estimator.

    Attributes:
        estimates_: Fitted Polars `DataFrame` containing `[*strata_cols, "target", "event", "n", "estimate", "lower", "upper"]`.
        train_draws_: Optional `(n_resamples, n_obs)` Dirichlet draw matrix when `method="bayesian_bootstrap"`.

    Examples:
        >>> from seroepi.aggregate import Aggregator
        >>> from seroepi.estimators import UnpooledEstimator
        >>> from seroepi.examples import KlebnetNeonatalSepsis
        >>> ds = KlebnetNeonatalSepsis.load()
        >>> agg = Aggregator(target=ds.var("K_locus"), strata=ds.var("Country")).fit(ds)
        >>> est = UnpooledEstimator(method="wilson", alpha=0.05).fit(agg)
        >>> est.ranked_targets(top_n=3)
        ['KL102', 'KL2', 'KL25']
    """

    model_family: str = "Unpooled"
    is_bayesian: bool = False
    ci_prefix: str = "CI"

    def __init__(
        self,
        method: Literal[
            "wilson",
            "clopper_pearson",
            "agresti_coull",
            "wald",
            "jeffreys",
            "bootstrap",
            "bayesian_bootstrap",
        ] = "wilson",
        alpha: float = 0.05,
        n_resamples: int = 1000,
        prior_concentration: float = 0.5,
        random_state: int | np.random.Generator | None = None,
        name: str | None = None,
    ) -> None:
        self.method = method
        self.alpha = alpha
        self.n_resamples = n_resamples
        self.prior_concentration = prior_concentration
        self.random_state = random_state
        self.name = name

    @property
    def _method_key(self) -> str:
        return self.method.lower() if isinstance(self.method, str) else str(self.method).lower()

    @property
    def is_bootstrapped(self) -> bool:
        """Whether this estimator uses resampling bootstrap inference."""
        return self._method_key in {"bootstrap", "bayesian_bootstrap"}

    @property
    def inference_type(self) -> Literal["analytical", "resampling"]:
        """Inference paradigm: analytical asymptotic/exact vs non-parametric resampling."""
        return "resampling" if self.is_bootstrapped else "analytical"

    @property
    def display_name(self) -> str:
        """Formatted display name for unpooled model."""
        if self.name:
            return self.name
        if self._method_key == "bayesian_bootstrap":
            return "Bayesian Bootstrap"
        if self._method_key == "bootstrap":
            return "Bootstrap"
        if self._method_key in {"wilson", "jeffreys", "wald", "clopper_pearson", "agresti_coull"}:
            return self._method_key.replace("_", "-").title()
        return self.model_family

    def describe_interval(self) -> str:
        """Descriptive string for uncertainty intervals."""
        if self._method_key == "bayesian_bootstrap":
            return f"{self.ci_percentage}% Bayesian bootstrap {self.ci_plural} ({self.n_resamples:,} resamples)"
        if self._method_key == "bootstrap":
            return f"{self.ci_percentage}% bootstrap {self.ci_plural} ({self.n_resamples:,} resamples)"
        method_title = self._method_key.replace("_", "-").title()
        return f"{self.ci_percentage}% {method_title} {self.ci_plural}"

    def _row_draws_for_frame(self, df: pl.DataFrame) -> np.ndarray | None:
        """Returns `(n_resamples, len(df))` Dirichlet bootstrap draws when `method == 'bayesian_bootstrap'`."""
        if self._method_key != "bayesian_bootstrap":
            return None
        if df.is_empty():
            return np.zeros((self.n_resamples, 0), dtype=float)
        if df is self.estimates_ and self.train_draws_ is not None:
            return self.train_draws_
        if self.is_compositional_:
            return bayesian_bootstrap_compositional_draws(
                df,
                self.strata_cols,
                n_resamples=self.n_resamples,
                prior_concentration=self.prior_concentration,
                random_state=self.random_state,
            )
        return bayesian_bootstrap_row_draws(
            df["event"].to_numpy().astype(float),
            df["n"].to_numpy().astype(float),
            n_resamples=self.n_resamples,
            prior_concentration=self.prior_concentration,
            random_state=self.random_state,
        )

    def fit(self, X: Aggregator | Any, y: Any = None, sample_weight: Any = None) -> Self:
        """Fits the unpooled prevalence estimator on a fitted `Aggregator`.

        Args:
            X: Input fitted `Aggregator` (or `TransmissionEstimator`) containing `event` and `n` columns.
            y: Ignored; present for scikit-learn pipeline compatibility.
            sample_weight: Ignored; present for scikit-learn pipeline compatibility.

        Returns:
            Fitted `self` with populated `estimates_`.
        """
        df = self._bind_aggregator(X)
        k = df["event"].to_numpy().astype(float)
        n = df["n"].to_numpy().astype(float)

        if self._method_key == "bayesian_bootstrap" and self.is_compositional_:
            self.train_draws_ = bayesian_bootstrap_compositional_draws(
                df,
                X.strata_cols,
                n_resamples=self.n_resamples,
                prior_concentration=self.prior_concentration,
                random_state=self.random_state,
            )
            safe_n = np.where(n > 0, n, 1.0)
            p = np.where(n > 0, k / safe_n, 0.0)
            _, lower, upper = self._summarize_draws(self.train_draws_, float(self.alpha))
            lower = np.where(n > 0, np.clip(lower, 0.0, 1.0), 0.0)
            upper = np.where(n > 0, np.clip(upper, 0.0, 1.0), 0.0)
        else:
            self.train_draws_ = None
            p, lower, upper = self._compute_ci(k, n)

        return self._finalize_fit(df, p, lower, upper, method=self.method)

    def predict(
        self, X: Aggregator | Any, return_interval: bool = False
    ) -> np.ndarray | tuple[np.ndarray, np.ndarray, np.ndarray]:
        """Predicts point prevalence proportions or `(estimate, lower, upper)` intervals for `X`.

        Args:
            X: Fitted `Aggregator` instance to evaluate.
            return_interval: If `True`, returns `(estimate, lower, upper)` 1D NumPy arrays;
                otherwise returns only the 1D `estimate` array.

        Returns:
            1D NumPy array of point estimates, or a 3-tuple of 1D NumPy arrays `(estimate, lower, upper)`.
        """
        fast = self._predict_training_fastpath(X, return_interval)
        if fast is not None:
            return fast

        assert self.estimates_ is not None
        assert self.aggregator_ is not None

        df = X.data_
        total_n = self.aggregator_.data_["n"].sum()
        total_events = self.aggregator_.data_["event"].sum()
        n_val = float(total_n) if total_n is not None else 0.0
        event_val = float(total_events) if total_events is not None else 0.0
        global_p = (event_val / n_val) if n_val > 0.0 else 0.0

        if "lower" in self.estimates_.columns:
            lower_val = self.estimates_["lower"].mean()
            global_lower: float = float(lower_val) if isinstance(lower_val, (int, float)) else global_p
        else:
            global_lower = global_p
        if "upper" in self.estimates_.columns:
            upper_val = self.estimates_["upper"].mean()
            global_upper: float = float(upper_val) if isinstance(upper_val, (int, float)) else global_p
        else:
            global_upper = global_p

        join_keys = [c for c in self.strata_cols if c in df.columns]
        if "target" in df.columns and "target" in self.estimates_.columns:
            join_keys.append("target")

        if join_keys:
            cols_to_join = list(dict.fromkeys(join_keys + ["estimate", "lower", "upper"]))
            merged = df.join(self.estimates_.select(cols_to_join), on=join_keys, how="left")
            p_arr = merged["estimate"].fill_null(global_p).to_numpy()
            lower = merged["lower"].fill_null(global_lower).to_numpy()
            upper = merged["upper"].fill_null(global_upper).to_numpy()
        else:
            p_arr = np.full(len(df), global_p)
            lower = np.full(len(df), global_lower)
            upper = np.full(len(df), global_upper)

        if return_interval:
            return p_arr, lower, upper
        return p_arr

    def _compute_ci(self, k: np.ndarray, n: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
        alpha = float(self.alpha)
        key = self._method_key
        if key == "bayesian_bootstrap":
            return bayesian_bootstrap_interval(
                k,
                n,
                alpha,
                n_resamples=self.n_resamples,
                prior_concentration=self.prior_concentration,
                random_state=self.random_state,
            )
        if key == "bootstrap":
            return bootstrap_interval(
                k,
                n,
                alpha,
                n_resamples=self.n_resamples,
                random_state=self.random_state,
            )
        if key == "clopper_pearson":
            return clopper_pearson_interval(k, n, alpha)
        if key == "agresti_coull":
            return agresti_coull_interval(k, n, alpha)
        if key == "wald":
            return wald_interval(k, n, alpha)
        if key == "jeffreys":
            return jeffreys_interval(k, n, alpha)
        return wilson_interval(k, n, alpha)

    def _attach_ci_from_counts(self, df: pl.DataFrame, est_col: str) -> pl.DataFrame:
        k = df["event"].to_numpy().astype(float)
        n = df["n"].to_numpy().astype(float)
        p, lower, upper = self._compute_ci(k, n)
        return df.with_columns(
            pl.Series(est_col, np.nan_to_num(p, nan=0.0)),
            pl.Series("lower", np.nan_to_num(lower, nan=0.0)),
            pl.Series("upper", np.nan_to_num(upper, nan=0.0)),
        )

    def _pool_strata_by_target(
        self,
        df: pl.DataFrame,
        *,
        weighting: Literal["pooled", "equal"] = "pooled",
    ) -> pl.DataFrame:
        target_col = self.target_col
        est_col = self.estimate.column
        if weighting == "equal":
            agg_df = (
                df.with_columns(
                    _cell_p=pl.when(pl.col("n") > 0).then(pl.col("event") / pl.col("n")).otherwise(0.0),
                    _inv_n=pl.when(pl.col("n") > 0).then(1.0 / pl.col("n")).otherwise(0.0),
                )
                .group_by(target_col, maintain_order=True)
                .agg(
                    pl.col("event").sum().alias("event"),
                    pl.col("n").sum().alias("n"),
                    pl.col("_cell_p").mean().alias("_mean_p"),
                    pl.col("_inv_n").sum().alias("_sum_inv_n"),
                    pl.len().alias("_n_strata"),
                )
            )
            mean_p = agg_df["_mean_p"].to_numpy().astype(float)
            sum_inv_n = agg_df["_inv_n" if "_inv_n" in agg_df.columns else "_sum_inv_n"].to_numpy().astype(float)
            n_strata = agg_df["_n_strata"].to_numpy().astype(float)
            n_eff = np.where(sum_inv_n > 0.0, (n_strata * n_strata) / sum_inv_n, 0.0)
            k_eff = mean_p * n_eff
            _, lower, upper = self._compute_ci(k_eff, n_eff)
            return agg_df.select([target_col, "event", "n"]).with_columns(
                pl.Series(est_col, np.nan_to_num(mean_p, nan=0.0)),
                pl.Series("lower", np.nan_to_num(lower, nan=0.0)),
                pl.Series("upper", np.nan_to_num(upper, nan=0.0)),
            )
        agg_df = df.group_by(target_col, maintain_order=True).agg(
            pl.col("event").sum().alias("event"),
            pl.col("n").sum().alias("n"),
        )
        return self._attach_ci_from_counts(agg_df, est_col)

    def _summarize_strata_and_lump(
        self,
        df: pl.DataFrame,
        x_limit: int | None,
        other_label: str,
    ) -> tuple[pl.DataFrame, list[str]]:
        stratum_col = self.single_stratum_col
        est_col = self.estimate.column
        stratum_counts = self._aggregate_stratum_counts(df)
        stratum_summary = self._attach_ci_from_counts(stratum_counts, est_col).sort(
            [est_col, stratum_col], descending=[True, False]
        )
        sorted_x = [str(x) for x in stratum_summary[stratum_col].to_list()]

        if x_limit is None or len(sorted_x) <= x_limit:
            return stratum_summary, sorted_x

        top_x = sorted_x[: x_limit - 1]
        kept_df = stratum_summary.head(x_limit - 1)
        binned_df = stratum_summary.slice(x_limit - 1)

        other_counts = binned_df.select(
            pl.lit(other_label).alias(stratum_col),
            pl.col("event").sum().alias("event"),
            pl.col("n").sum().alias("n"),
        )
        other_df = self._attach_ci_from_counts(other_counts, est_col)
        top_df = pl.concat([kept_df, other_df], how="diagonal_relaxed")
        return top_df, [*top_x, other_label]

    def _compute_cumulative_coverage_columns(self, df_sorted: pl.DataFrame) -> pl.DataFrame:
        k = df_sorted["cum_event"].to_numpy().astype(float)
        n = df_sorted["max_n"].to_numpy().astype(float)
        if self._method_key == "bayesian_bootstrap" and self.is_compositional_:
            draws = bayesian_bootstrap_compositional_draws(
                df_sorted,
                (self.single_stratum_col,),
                n_resamples=self.n_resamples,
                prior_concentration=self.prior_concentration,
                random_state=self.random_state,
            )
            cum_draws = self._cumulative_draws_by_stratum(df_sorted, draws)
            safe_n = np.where(n > 0, n, 1.0)
            p = np.where(n > 0, k / safe_n, 0.0)
            _, lower, upper = self._summarize_draws(cum_draws, float(self.alpha))
            lower = np.where(n > 0, np.clip(lower, 0.0, 1.0), 0.0)
            upper = np.where(n > 0, np.clip(upper, 0.0, 1.0), 0.0)
        else:
            p, lower, upper = self._compute_ci(k, n)
        return df_sorted.with_columns(
            cum_estimate=pl.Series(np.nan_to_num(p, nan=0.0)),
            cum_lower=pl.Series(np.nan_to_num(lower, nan=0.0)),
            cum_upper=pl.Series(np.nan_to_num(upper, nan=0.0)),
        )

is_bootstrapped property

is_bootstrapped: bool

Whether this estimator uses resampling bootstrap inference.

inference_type property

inference_type: Literal['analytical', 'resampling']

Inference paradigm: analytical asymptotic/exact vs non-parametric resampling.

display_name property

display_name: str

Formatted display name for unpooled model.

describe_interval

describe_interval() -> str

Descriptive string for uncertainty intervals.

Source code in src/seroepi/estimators/unpooled.py
def describe_interval(self) -> str:
    """Descriptive string for uncertainty intervals."""
    if self._method_key == "bayesian_bootstrap":
        return f"{self.ci_percentage}% Bayesian bootstrap {self.ci_plural} ({self.n_resamples:,} resamples)"
    if self._method_key == "bootstrap":
        return f"{self.ci_percentage}% bootstrap {self.ci_plural} ({self.n_resamples:,} resamples)"
    method_title = self._method_key.replace("_", "-").title()
    return f"{self.ci_percentage}% {method_title} {self.ci_plural}"

fit

fit(
    X: Aggregator | Any,
    y: Any = None,
    sample_weight: Any = None,
) -> Self

Fits the unpooled prevalence estimator on a fitted Aggregator.

Parameters:

Name Type Description Default
X Aggregator | Any

Input fitted Aggregator (or TransmissionEstimator) containing event and n columns.

required
y Any

Ignored; present for scikit-learn pipeline compatibility.

None
sample_weight Any

Ignored; present for scikit-learn pipeline compatibility.

None

Returns:

Type Description
Self

Fitted self with populated estimates_.

Source code in src/seroepi/estimators/unpooled.py
def fit(self, X: Aggregator | Any, y: Any = None, sample_weight: Any = None) -> Self:
    """Fits the unpooled prevalence estimator on a fitted `Aggregator`.

    Args:
        X: Input fitted `Aggregator` (or `TransmissionEstimator`) containing `event` and `n` columns.
        y: Ignored; present for scikit-learn pipeline compatibility.
        sample_weight: Ignored; present for scikit-learn pipeline compatibility.

    Returns:
        Fitted `self` with populated `estimates_`.
    """
    df = self._bind_aggregator(X)
    k = df["event"].to_numpy().astype(float)
    n = df["n"].to_numpy().astype(float)

    if self._method_key == "bayesian_bootstrap" and self.is_compositional_:
        self.train_draws_ = bayesian_bootstrap_compositional_draws(
            df,
            X.strata_cols,
            n_resamples=self.n_resamples,
            prior_concentration=self.prior_concentration,
            random_state=self.random_state,
        )
        safe_n = np.where(n > 0, n, 1.0)
        p = np.where(n > 0, k / safe_n, 0.0)
        _, lower, upper = self._summarize_draws(self.train_draws_, float(self.alpha))
        lower = np.where(n > 0, np.clip(lower, 0.0, 1.0), 0.0)
        upper = np.where(n > 0, np.clip(upper, 0.0, 1.0), 0.0)
    else:
        self.train_draws_ = None
        p, lower, upper = self._compute_ci(k, n)

    return self._finalize_fit(df, p, lower, upper, method=self.method)

predict

predict(
    X: Aggregator | Any, return_interval: bool = False
) -> ndarray | tuple[ndarray, ndarray, ndarray]

Predicts point prevalence proportions or (estimate, lower, upper) intervals for X.

Parameters:

Name Type Description Default
X Aggregator | Any

Fitted Aggregator instance to evaluate.

required
return_interval bool

If True, returns (estimate, lower, upper) 1D NumPy arrays; otherwise returns only the 1D estimate array.

False

Returns:

Type Description
ndarray | tuple[ndarray, ndarray, ndarray]

1D NumPy array of point estimates, or a 3-tuple of 1D NumPy arrays (estimate, lower, upper).

Source code in src/seroepi/estimators/unpooled.py
def predict(
    self, X: Aggregator | Any, return_interval: bool = False
) -> np.ndarray | tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Predicts point prevalence proportions or `(estimate, lower, upper)` intervals for `X`.

    Args:
        X: Fitted `Aggregator` instance to evaluate.
        return_interval: If `True`, returns `(estimate, lower, upper)` 1D NumPy arrays;
            otherwise returns only the 1D `estimate` array.

    Returns:
        1D NumPy array of point estimates, or a 3-tuple of 1D NumPy arrays `(estimate, lower, upper)`.
    """
    fast = self._predict_training_fastpath(X, return_interval)
    if fast is not None:
        return fast

    assert self.estimates_ is not None
    assert self.aggregator_ is not None

    df = X.data_
    total_n = self.aggregator_.data_["n"].sum()
    total_events = self.aggregator_.data_["event"].sum()
    n_val = float(total_n) if total_n is not None else 0.0
    event_val = float(total_events) if total_events is not None else 0.0
    global_p = (event_val / n_val) if n_val > 0.0 else 0.0

    if "lower" in self.estimates_.columns:
        lower_val = self.estimates_["lower"].mean()
        global_lower: float = float(lower_val) if isinstance(lower_val, (int, float)) else global_p
    else:
        global_lower = global_p
    if "upper" in self.estimates_.columns:
        upper_val = self.estimates_["upper"].mean()
        global_upper: float = float(upper_val) if isinstance(upper_val, (int, float)) else global_p
    else:
        global_upper = global_p

    join_keys = [c for c in self.strata_cols if c in df.columns]
    if "target" in df.columns and "target" in self.estimates_.columns:
        join_keys.append("target")

    if join_keys:
        cols_to_join = list(dict.fromkeys(join_keys + ["estimate", "lower", "upper"]))
        merged = df.join(self.estimates_.select(cols_to_join), on=join_keys, how="left")
        p_arr = merged["estimate"].fill_null(global_p).to_numpy()
        lower = merged["lower"].fill_null(global_lower).to_numpy()
        upper = merged["upper"].fill_null(global_upper).to_numpy()
    else:
        p_arr = np.full(len(df), global_p)
        lower = np.full(len(df), global_lower)
        upper = np.full(len(df), global_upper)

    if return_interval:
        return p_arr, lower, upper
    return p_arr

Hierarchical Bayesian MCMC & SVI Estimators (seroepi.estimators.hierarchical)

hierarchical

Hierarchical Bayesian prevalence estimators via NumPyro NUTS MCMC and SVI.

Classes:

Name Description
BaseHierarchicalEstimator

Abstract base class for hierarchical Bayesian prevalence estimators.

HierarchicalMCMCEstimator

Hierarchical Bayesian model for prevalence estimation fitted via NumPyro NUTS MCMC.

HierarchicalSVIEstimator

Hierarchical Bayesian model for prevalence estimation fitted via fast Stochastic Variational Inference (SVI).

BaseHierarchicalEstimator

Bases: BayesianEstimator

Abstract base class for hierarchical Bayesian prevalence estimators.

Unifies data pre-processing, compound strata grouping, label encoding, 1D observed-cell partial pooling, posterior derivation, and scikit-learn prediction for hierarchical models.

Attributes:

Name Type Description
engine str | None

Inference engine identifier ("MCMC" or "SVI").

estimate_method str

Method label recorded in estimates_["method"].

likelihood Literal['binomial', 'beta_binomial']

Observation likelihood kernel ("binomial" or "beta_binomial").

samples_ dict[str, ndarray] | None

Dictionary of posterior parameter draws extracted after fitting.

extra_fields_ dict[str, ndarray] | None

Dictionary of sampler diagnostic fields (MCMC only).

losses_ ndarray | None

1D NumPy array of variational ELBO losses per optimization step (SVI only).

encoders_ dict[str, LabelEncoder]

Mapping from target/stratum column names to fitted LabelEncoder instances.

cell_index_ dict[tuple[str, str], int]

Mapping from (target_str, group_str) tuples to 1D cell indices.

Methods:

Name Description
fit

Fits the hierarchical Bayesian estimator on a fitted Aggregator.

predict

Predicts posterior mean prevalence (and optional credible intervals) for X.

Source code in src/seroepi/estimators/hierarchical.py
class BaseHierarchicalEstimator(BayesianEstimator):
    """Abstract base class for hierarchical Bayesian prevalence estimators.

    Unifies data pre-processing, compound strata grouping, label encoding,
    1D observed-cell partial pooling, posterior derivation, and scikit-learn
    prediction for hierarchical models.

    Attributes:
        engine: Inference engine identifier (``"MCMC"`` or ``"SVI"``).
        estimate_method: Method label recorded in ``estimates_["method"]``.
        likelihood: Observation likelihood kernel (``"binomial"`` or ``"beta_binomial"``).
        samples_: Dictionary of posterior parameter draws extracted after fitting.
        extra_fields_: Dictionary of sampler diagnostic fields (MCMC only).
        losses_: 1D NumPy array of variational ELBO losses per optimization step (SVI only).
        encoders_: Mapping from target/stratum column names to fitted ``LabelEncoder`` instances.
        cell_index_: Mapping from ``(target_str, group_str)`` tuples to 1D cell indices.
    """

    engine: str | None = None
    estimate_method: str = "hierarchical"
    likelihood: Literal["binomial", "beta_binomial"]
    samples_: dict[str, np.ndarray] | None = None
    extra_fields_: dict[str, np.ndarray] | None = None
    losses_: np.ndarray | None = None
    encoders_: dict[str, LabelEncoder]
    cell_index_: dict[tuple[str, str], int]

    @staticmethod
    def _binomial_model(
        target_idx: Any,
        n: Any,
        event: Any = None,
        cell_idx: Any = None,
        *,
        n_targets: int,
        n_cells: int,
    ) -> None:
        """NumPyro model for hierarchical binomial prevalence with target-by-group partial pooling."""
        alpha: Any = samp("alpha", dist.Normal(0.0, 1.5))
        b_target: Any = samp("b_target", dist.Normal(0.0, 1.0).expand([n_targets]))
        sd_group: Any = samp("sd_group", dist.HalfNormal(1.0))
        z_group: Any = samp("z_group", dist.Normal(0.0, 1.0).expand([n_cells]))
        r_cell: Any = (z_group[cell_idx] if cell_idx is not None else z_group) * sd_group
        logit_p: Any = alpha + b_target[target_idx] + r_cell
        samp("obs", dist.Binomial(total_count=n, logits=logit_p), obs=event)

    @staticmethod
    def _beta_binomial_model(
        target_idx: Any,
        n: Any,
        event: Any = None,
        cell_idx: Any = None,
        *,
        n_targets: int,
        n_cells: int,
    ) -> None:
        """NumPyro model for overdispersed beta-binomial prevalence with target-by-group partial pooling."""
        alpha: Any = samp("alpha", dist.Normal(0.0, 1.5))
        b_target: Any = samp("b_target", dist.Normal(0.0, 1.0).expand([n_targets]))
        sd_group: Any = samp("sd_group", dist.HalfNormal(1.0))
        z_group: Any = samp("z_group", dist.Normal(0.0, 1.0).expand([n_cells]))
        r_cell: Any = (z_group[cell_idx] if cell_idx is not None else z_group) * sd_group
        logit_p: Any = alpha + b_target[target_idx] + r_cell
        p: Any = jax.nn.sigmoid(logit_p)
        phi: Any = samp("phi", dist.Exponential(0.1))
        a: Any = (p * phi) + 1e-4
        b: Any = ((1.0 - p) * phi) + 1e-4
        samp("obs", dist.BetaBinomial(a, b, total_count=n), obs=event)

    @staticmethod
    def _compute_prevalence_draws(
        alpha: np.ndarray,
        b_target: np.ndarray,
        z_group: np.ndarray,
        sd_group: np.ndarray,
        target_idx: np.ndarray,
        cell_idx: np.ndarray | None = None,
    ) -> np.ndarray:
        """Computes (n_samples, n_obs) posterior prevalence draws on the probability scale."""
        alpha_col = np.asarray(alpha, dtype=np.float32)
        if alpha_col.ndim == 1:
            alpha_col = alpha_col[:, None]
        sd_col = np.asarray(sd_group, dtype=np.float32)
        if sd_col.ndim == 1:
            sd_col = sd_col[:, None]

        b_mat = np.asarray(b_target, dtype=np.float32)[:, target_idx]
        z_mat = np.asarray(z_group, dtype=np.float32)

        if cell_idx is None:
            eff = z_mat * sd_col
        else:
            safe_idx = np.where(cell_idx >= 0, cell_idx, 0)
            known_mask = (cell_idx >= 0)[None, :]
            eff = np.where(known_mask, z_mat[:, safe_idx] * sd_col, np.float32(0.0))

        logit_p = alpha_col + b_mat + eff
        return np.asarray(expit(logit_p), dtype=np.float64)

    @staticmethod
    def _summarize_posterior_draws(
        p_draws: np.ndarray,
        alpha: float = 0.05,
    ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
        """Computes posterior mean and equal-tailed (1 - alpha) credible intervals in a single quantile pass."""
        return BaseHierarchicalEstimator._summarize_draws(p_draws, alpha)

    @staticmethod
    def _transform_safe(encoder: LabelEncoder, vals: np.ndarray) -> np.ndarray:
        """Vectorized transformation mapping out-of-vocabulary categories to classes_[0]."""
        mask = np.isin(vals, encoder.classes_)
        safe_vals = np.where(mask, vals, encoder.classes_[0])
        return np.asarray(encoder.transform(safe_vals), dtype=np.int32)

    def _get_model_fn(self, *, n_targets: int, n_cells: int) -> Callable[..., None]:
        base_fn = self._beta_binomial_model if self.likelihood == "beta_binomial" else self._binomial_model
        return partial(base_fn, n_targets=n_targets, n_cells=n_cells)

    @abstractmethod
    def _run_inference(self, jax_data: dict[str, Any], *, n_targets: int, n_cells: int) -> None:
        """Abstract inference method implemented by subclasses (MCMC or SVI)."""
        raise NotImplementedError

    def _extract_target_and_group_strings(self, df: pl.DataFrame) -> tuple[str, np.ndarray, np.ndarray]:
        """Extracts string arrays for target and compound/single stratum group columns."""
        df_mod = df
        strata_cols = [s.column for s in self.strata_]
        if len(strata_cols) > 1:
            matching_strata = [c for c in strata_cols if c in df.columns]
            if len(matching_strata) == len(strata_cols):
                df_mod = df_mod.with_columns(
                    pl.concat_str([pl.col(c).cast(pl.String) for c in strata_cols], separator="__").alias(
                        "_compound_group"
                    )
                )
                group_col = "_compound_group"
            else:
                df_mod = df_mod.with_columns(_dummy_group=pl.lit("Global"))
                group_col = "_dummy_group"
        elif strata_cols and strata_cols[0] in df.columns:
            group_col = strata_cols[0]
        else:
            df_mod = df_mod.with_columns(_dummy_group=pl.lit("Global"))
            group_col = "_dummy_group"

        target_col = "target" if "target" in df_mod.columns else "_dummy_target"
        if "target" not in df_mod.columns:
            df_mod = df_mod.with_columns(_dummy_target=pl.lit(self.target_.title or "Default"))

        target_str = df_mod[target_col].cast(pl.String).to_numpy()
        group_str = df_mod[group_col].cast(pl.String).to_numpy()
        return target_col, target_str, group_str

    def _posterior_draws_for_frame(self, df: pl.DataFrame) -> np.ndarray:
        """Extracts (n_samples, len(df)) posterior prevalence draws for rows in df."""
        assert self.samples_ is not None
        if self.train_draws_ is not None and (
            df is self.estimates_ or (self.aggregator_ is not None and df is self.aggregator_.data_)
        ):
            return self.train_draws_

        target_col, target_str, group_str = self._extract_target_and_group_strings(df)
        cell_idx = np.fromiter(
            (self.cell_index_.get((str(t), str(g)), -1) for t, g in zip(target_str, group_str, strict=False)),
            dtype=np.int32,
            count=len(df),
        )

        if self.train_draws_ is not None and np.all(cell_idx >= 0):
            return self.train_draws_[:, cell_idx]

        target_idx = self._transform_safe(self.encoders_[target_col], target_str)
        return self._compute_prevalence_draws(
            self.samples_["alpha"],
            self.samples_["b_target"],
            self.samples_["z_group"],
            self.samples_["sd_group"],
            target_idx,
            cell_idx=cell_idx,
        )

    def _row_draws_for_frame(self, df: pl.DataFrame) -> np.ndarray | None:
        return self._posterior_draws_for_frame(df)

    def fit(self, X: Aggregator | Any, y: Any = None, sample_weight: Any = None) -> Self:
        """Fits the hierarchical Bayesian estimator on a fitted ``Aggregator``.

        Encodes target and stratum categories, runs NumPyro MCMC or SVI inference,
        and derives posterior mean prevalence and equal-tailed credible intervals.

        Args:
            X: Fitted ``Aggregator`` instance containing ``event`` and ``n`` columns.
            y: Ignored; present for scikit-learn pipeline compatibility.
            sample_weight: Ignored; present for scikit-learn pipeline compatibility.

        Returns:
            The fitted hierarchical estimator instance (``self``).

        Raises:
            TypeError: If ``X`` is not an ``Aggregator`` instance.
            RuntimeError: If ``X`` has not been fitted yet.
        """
        df = self._bind_aggregator(X)

        if X.is_compositional_ and not X.is_padded_:
            self._record_warning(
                "Fitting hierarchical Bayesian estimator on unpadded compositional Aggregator. "
                "Unobserved target categories in strata will be omitted from estimation, which may bias "
                "prevalence estimates. Consider aggregating with `pad_zeros=True`.",
                UserWarning,
                stacklevel=3,
            )

        target_col, target_str, group_str = self._extract_target_and_group_strings(df)
        group_col = (
            self.strata_[0].column
            if len(self.strata_) == 1
            else ("_compound_group" if len(self.strata_) > 1 else "_dummy_group")
        )

        target_le = LabelEncoder()
        target_idx_np = np.asarray(target_le.fit_transform(target_str), dtype=np.int32)
        group_le = LabelEncoder()
        group_le.fit(group_str)
        self.encoders_ = {target_col: target_le, group_col: group_le}

        self.cell_index_ = {}
        cell_idx_np = np.empty(len(df), dtype=np.int32)
        for idx, (t_val, g_val) in enumerate(zip(target_str, group_str, strict=False)):
            key = (str(t_val), str(g_val))
            existing = self.cell_index_.get(key)
            if existing is None:
                existing = len(self.cell_index_)
                self.cell_index_[key] = existing
            cell_idx_np[idx] = existing

        n_targets = len(target_le.classes_)
        n_cells = len(self.cell_index_)
        is_bijective = n_cells == len(df)

        prog = current_progress()
        prog.report(0.05, message=f"Fitting {self.display_name}", detail="Encoding strata and preparing JAX tensors...")

        jax_data: dict[str, Any] = {
            "target_idx": jnp.asarray(target_idx_np, dtype=jnp.int32),
            "n": jnp.asarray(df["n"].to_numpy(), dtype=jnp.int32),
            "event": jnp.asarray(df["event"].to_numpy(), dtype=jnp.int32),
        }
        if not is_bijective:
            jax_data["cell_idx"] = jnp.asarray(cell_idx_np, dtype=jnp.int32)

        self._run_inference(jax_data, n_targets=n_targets, n_cells=n_cells)
        assert self.samples_ is not None

        prog.report(0.92, message=f"Fitting {self.display_name}", detail="Computing posterior prevalence intervals...")
        self.train_draws_ = self._compute_prevalence_draws(
            self.samples_["alpha"],
            self.samples_["b_target"],
            self.samples_["z_group"],
            self.samples_["sd_group"],
            target_idx_np,
            cell_idx=None if is_bijective else cell_idx_np,
        )
        mean_p, lower_p, upper_p = self._summarize_draws(self.train_draws_, self.alpha)
        self._finalize_fit(df, mean_p, lower_p, upper_p, method=self.estimate_method)
        prog.report(1.0, message=f"Fitted {self.display_name}", detail="Posterior estimation complete")
        return self

    def predict(
        self, X: Aggregator | Any, return_interval: bool = False
    ) -> np.ndarray | tuple[np.ndarray, np.ndarray, np.ndarray]:
        """Predicts posterior mean prevalence (and optional credible intervals) for ``X``.

        Args:
            X: Fitted ``Aggregator`` instance to predict on.
            return_interval: Whether to also return ``(lower, upper)`` credible interval arrays.

        Returns:
            A 1D float64 NumPy array of posterior mean prevalence estimates, or a 3-tuple
            ``(estimate, lower, upper)`` when ``return_interval=True``.

        Raises:
            RuntimeError: If the estimator has not been fitted yet.
            TypeError: If ``X`` is not a fitted ``Aggregator``.
        """
        fast = self._predict_training_fastpath(X, return_interval)
        if fast is not None:
            return fast

        p_draws = self._posterior_draws_for_frame(X.data_)
        mean_p, lower_p, upper_p = self._summarize_draws(p_draws, self.alpha)

        est = np.asarray(mean_p, dtype=np.float64)
        if return_interval:
            return est, np.asarray(lower_p, dtype=np.float64), np.asarray(upper_p, dtype=np.float64)
        return est

    def _pool_strata_by_target(
        self,
        df: pl.DataFrame,
        *,
        weighting: Literal["pooled", "equal"] = "pooled",
    ) -> pl.DataFrame:
        target_col = self.target_col
        est_col = self.estimate.column
        p_draws = self._posterior_draws_for_frame(df)
        agg_df, _, pooled_draws = self._pool_draws_by_target(df, p_draws, target_col, weighting=weighting)
        mean_p, lower_p, upper_p = self._summarize_draws(pooled_draws, self.alpha)
        return agg_df.with_columns(
            pl.Series(est_col, mean_p),
            pl.Series("lower", lower_p),
            pl.Series("upper", upper_p),
        )

    def _summarize_strata_and_lump(
        self,
        df: pl.DataFrame,
        x_limit: int | None,
        other_label: str,
    ) -> tuple[pl.DataFrame, list[str]]:
        stratum_col = self.single_stratum_col
        est_col = self.estimate.column
        stratum_counts = self._aggregate_stratum_counts(df)

        p_draws = self._posterior_draws_for_frame(df)
        stratum_vals = df[stratum_col].cast(pl.String).to_numpy()
        strata_order = stratum_counts[stratum_col].cast(pl.String).to_list()

        stratum_draws = np.zeros((p_draws.shape[0], len(strata_order)), dtype=float)
        for idx, s_val in enumerate(strata_order):
            mask = stratum_vals == s_val
            stratum_draws[:, idx] = np.clip(np.sum(p_draws[:, mask], axis=1), 0.0, 1.0)

        mean_p, lower_p, upper_p = self._summarize_draws(stratum_draws, self.alpha)
        stratum_summary = stratum_counts.with_columns(
            pl.Series(est_col, mean_p),
            pl.Series("lower", lower_p),
            pl.Series("upper", upper_p),
            pl.Series("_orig_idx", list(range(len(strata_order)))),
        ).sort([est_col, stratum_col], descending=[True, False])
        sorted_indices = stratum_summary["_orig_idx"].to_numpy()
        stratum_summary = stratum_summary.drop("_orig_idx")
        sorted_draws = stratum_draws[:, sorted_indices]
        sorted_x = [str(x) for x in stratum_summary[stratum_col].to_list()]

        if x_limit is None or len(sorted_x) <= x_limit:
            return stratum_summary, sorted_x

        top_x = sorted_x[: x_limit - 1]
        kept_df = stratum_summary.head(x_limit - 1)
        binned_df = stratum_summary.slice(x_limit - 1)
        binned_draws = sorted_draws[:, x_limit - 1 :]

        w = binned_df["n"].to_numpy().astype(float)
        w_sum = float(np.sum(w))
        other_draw = (binned_draws @ (w / w_sum)) if w_sum > 0.0 else np.mean(binned_draws, axis=1)
        mean_o, low_o, up_o = self._summarize_draws(other_draw[:, None], self.alpha)

        other_df = binned_df.select(
            pl.lit(other_label).alias(stratum_col),
            pl.col("event").sum().alias("event"),
            pl.col("n").sum().alias("n"),
        ).with_columns(
            pl.Series(est_col, mean_o),
            pl.Series("lower", low_o),
            pl.Series("upper", up_o),
        )
        top_df = pl.concat([kept_df, other_df], how="diagonal_relaxed")
        return top_df, [*top_x, other_label]

    def _compute_cumulative_coverage_columns(self, df_sorted: pl.DataFrame) -> pl.DataFrame:
        p_draws = self._posterior_draws_for_frame(df_sorted)
        cum_draws = self._cumulative_draws_by_stratum(df_sorted, p_draws)
        mean_p, lower_p, upper_p = self._summarize_draws(cum_draws, self.alpha)
        return df_sorted.with_columns(
            cum_estimate=pl.Series(mean_p),
            cum_lower=pl.Series(lower_p),
            cum_upper=pl.Series(upper_p),
        )

fit

fit(
    X: Aggregator | Any,
    y: Any = None,
    sample_weight: Any = None,
) -> Self

Fits the hierarchical Bayesian estimator on a fitted Aggregator.

Encodes target and stratum categories, runs NumPyro MCMC or SVI inference, and derives posterior mean prevalence and equal-tailed credible intervals.

Parameters:

Name Type Description Default
X Aggregator | Any

Fitted Aggregator instance containing event and n columns.

required
y Any

Ignored; present for scikit-learn pipeline compatibility.

None
sample_weight Any

Ignored; present for scikit-learn pipeline compatibility.

None

Returns:

Type Description
Self

The fitted hierarchical estimator instance (self).

Raises:

Type Description
TypeError

If X is not an Aggregator instance.

RuntimeError

If X has not been fitted yet.

Source code in src/seroepi/estimators/hierarchical.py
def fit(self, X: Aggregator | Any, y: Any = None, sample_weight: Any = None) -> Self:
    """Fits the hierarchical Bayesian estimator on a fitted ``Aggregator``.

    Encodes target and stratum categories, runs NumPyro MCMC or SVI inference,
    and derives posterior mean prevalence and equal-tailed credible intervals.

    Args:
        X: Fitted ``Aggregator`` instance containing ``event`` and ``n`` columns.
        y: Ignored; present for scikit-learn pipeline compatibility.
        sample_weight: Ignored; present for scikit-learn pipeline compatibility.

    Returns:
        The fitted hierarchical estimator instance (``self``).

    Raises:
        TypeError: If ``X`` is not an ``Aggregator`` instance.
        RuntimeError: If ``X`` has not been fitted yet.
    """
    df = self._bind_aggregator(X)

    if X.is_compositional_ and not X.is_padded_:
        self._record_warning(
            "Fitting hierarchical Bayesian estimator on unpadded compositional Aggregator. "
            "Unobserved target categories in strata will be omitted from estimation, which may bias "
            "prevalence estimates. Consider aggregating with `pad_zeros=True`.",
            UserWarning,
            stacklevel=3,
        )

    target_col, target_str, group_str = self._extract_target_and_group_strings(df)
    group_col = (
        self.strata_[0].column
        if len(self.strata_) == 1
        else ("_compound_group" if len(self.strata_) > 1 else "_dummy_group")
    )

    target_le = LabelEncoder()
    target_idx_np = np.asarray(target_le.fit_transform(target_str), dtype=np.int32)
    group_le = LabelEncoder()
    group_le.fit(group_str)
    self.encoders_ = {target_col: target_le, group_col: group_le}

    self.cell_index_ = {}
    cell_idx_np = np.empty(len(df), dtype=np.int32)
    for idx, (t_val, g_val) in enumerate(zip(target_str, group_str, strict=False)):
        key = (str(t_val), str(g_val))
        existing = self.cell_index_.get(key)
        if existing is None:
            existing = len(self.cell_index_)
            self.cell_index_[key] = existing
        cell_idx_np[idx] = existing

    n_targets = len(target_le.classes_)
    n_cells = len(self.cell_index_)
    is_bijective = n_cells == len(df)

    prog = current_progress()
    prog.report(0.05, message=f"Fitting {self.display_name}", detail="Encoding strata and preparing JAX tensors...")

    jax_data: dict[str, Any] = {
        "target_idx": jnp.asarray(target_idx_np, dtype=jnp.int32),
        "n": jnp.asarray(df["n"].to_numpy(), dtype=jnp.int32),
        "event": jnp.asarray(df["event"].to_numpy(), dtype=jnp.int32),
    }
    if not is_bijective:
        jax_data["cell_idx"] = jnp.asarray(cell_idx_np, dtype=jnp.int32)

    self._run_inference(jax_data, n_targets=n_targets, n_cells=n_cells)
    assert self.samples_ is not None

    prog.report(0.92, message=f"Fitting {self.display_name}", detail="Computing posterior prevalence intervals...")
    self.train_draws_ = self._compute_prevalence_draws(
        self.samples_["alpha"],
        self.samples_["b_target"],
        self.samples_["z_group"],
        self.samples_["sd_group"],
        target_idx_np,
        cell_idx=None if is_bijective else cell_idx_np,
    )
    mean_p, lower_p, upper_p = self._summarize_draws(self.train_draws_, self.alpha)
    self._finalize_fit(df, mean_p, lower_p, upper_p, method=self.estimate_method)
    prog.report(1.0, message=f"Fitted {self.display_name}", detail="Posterior estimation complete")
    return self

predict

predict(
    X: Aggregator | Any, return_interval: bool = False
) -> ndarray | tuple[ndarray, ndarray, ndarray]

Predicts posterior mean prevalence (and optional credible intervals) for X.

Parameters:

Name Type Description Default
X Aggregator | Any

Fitted Aggregator instance to predict on.

required
return_interval bool

Whether to also return (lower, upper) credible interval arrays.

False

Returns:

Type Description
ndarray | tuple[ndarray, ndarray, ndarray]

A 1D float64 NumPy array of posterior mean prevalence estimates, or a 3-tuple

ndarray | tuple[ndarray, ndarray, ndarray]

(estimate, lower, upper) when return_interval=True.

Raises:

Type Description
RuntimeError

If the estimator has not been fitted yet.

TypeError

If X is not a fitted Aggregator.

Source code in src/seroepi/estimators/hierarchical.py
def predict(
    self, X: Aggregator | Any, return_interval: bool = False
) -> np.ndarray | tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Predicts posterior mean prevalence (and optional credible intervals) for ``X``.

    Args:
        X: Fitted ``Aggregator`` instance to predict on.
        return_interval: Whether to also return ``(lower, upper)`` credible interval arrays.

    Returns:
        A 1D float64 NumPy array of posterior mean prevalence estimates, or a 3-tuple
        ``(estimate, lower, upper)`` when ``return_interval=True``.

    Raises:
        RuntimeError: If the estimator has not been fitted yet.
        TypeError: If ``X`` is not a fitted ``Aggregator``.
    """
    fast = self._predict_training_fastpath(X, return_interval)
    if fast is not None:
        return fast

    p_draws = self._posterior_draws_for_frame(X.data_)
    mean_p, lower_p, upper_p = self._summarize_draws(p_draws, self.alpha)

    est = np.asarray(mean_p, dtype=np.float64)
    if return_interval:
        return est, np.asarray(lower_p, dtype=np.float64), np.asarray(upper_p, dtype=np.float64)
    return est

HierarchicalMCMCEstimator

Bases: BaseHierarchicalEstimator

Hierarchical Bayesian model for prevalence estimation fitted via NumPyro NUTS MCMC.

Models target effects and random group interactions, estimating full posterior distributions and offering formal MCMC convergence diagnostics (R-hat, effective sample size).

Parameters:

Name Type Description Default
num_samples int

Number of posterior samples to draw per chain.

1500
num_chains int

Number of parallel MCMC chains.

4
num_warmup int

Number of warmup/burn-in steps.

1000
likelihood Literal['binomial', 'beta_binomial']

Binomial or overdispersed beta_binomial likelihood kernel.

'binomial'
alpha float

Significance level for credible intervals (default 0.05 for 95% CrIs).

0.05
chain_method Literal['auto', 'parallel', 'vectorized', 'sequential']

MCMC chain execution strategy ('auto', 'parallel', 'vectorized', or 'sequential').

'auto'
target_accept_prob float

NUTS target acceptance probability (default 0.8).

0.8
max_tree_depth int

NUTS maximum tree depth (default 10).

10
init_strategy Literal['median', 'uniform', 'sample'] | Callable[..., Any]

Parameter initialization strategy ('median', 'uniform', 'sample', or custom callable).

'median'
random_state int | None

Random seed for reproducibility.

42
name str | None

Optional custom display name for the estimator.

None

Attributes:

Name Type Description
samples_ dict[str, ndarray] | None

Dictionary mapping model parameter names to NumPy arrays of posterior draws.

extra_fields_ dict[str, ndarray] | None

Dictionary of NUTS sampler diagnostic fields (e.g., diverging).

estimates_ DataFrame | None

Polars DataFrame containing posterior mean prevalence and credible intervals.

Examples:

>>> import polars as pl
>>> from seroepi.dataset import Dataset
>>> from seroepi.aggregate import Aggregator
>>> from seroepi.estimators.hierarchical import HierarchicalMCMCEstimator
>>> from seroepi.variables import Identifier, Spatial, Trait
>>> ds = Dataset(
...     pl.DataFrame({"id": ["s1", "s2", "s3"], "region": ["A", "A", "B"], "k": ["K1", "K2", "K1"]}),
...     variables={"id": Identifier("id"), "region": Spatial("region"), "k": Trait("k", compositional=True)},
... )
>>> agg = Aggregator(target=ds.var("k"), strata=[ds.var("region")], pad_zeros=True).fit(ds)
>>> est = HierarchicalMCMCEstimator(num_warmup=50, num_samples=50, num_chains=1).fit(agg)
>>> est.is_fitted_
True

Methods:

Name Description
diagnostics

Returns MCMC convergence diagnostics (R-hat, ESS, mean, std) as a Polars DataFrame.

Source code in src/seroepi/estimators/hierarchical.py
class HierarchicalMCMCEstimator(BaseHierarchicalEstimator):
    """Hierarchical Bayesian model for prevalence estimation fitted via NumPyro NUTS MCMC.

    Models target effects and random group interactions, estimating full posterior distributions
    and offering formal MCMC convergence diagnostics (R-hat, effective sample size).

    Args:
        num_samples: Number of posterior samples to draw per chain.
        num_chains: Number of parallel MCMC chains.
        num_warmup: Number of warmup/burn-in steps.
        likelihood: Binomial or overdispersed beta_binomial likelihood kernel.
        alpha: Significance level for credible intervals (default 0.05 for 95% CrIs).
        chain_method: MCMC chain execution strategy ('auto', 'parallel', 'vectorized', or 'sequential').
        target_accept_prob: NUTS target acceptance probability (default 0.8).
        max_tree_depth: NUTS maximum tree depth (default 10).
        init_strategy: Parameter initialization strategy ('median', 'uniform', 'sample', or custom callable).
        random_state: Random seed for reproducibility.
        name: Optional custom display name for the estimator.

    Attributes:
        samples_: Dictionary mapping model parameter names to NumPy arrays of posterior draws.
        extra_fields_: Dictionary of NUTS sampler diagnostic fields (e.g., ``diverging``).
        estimates_: Polars ``DataFrame`` containing posterior mean prevalence and credible intervals.

    Examples:
        >>> import polars as pl
        >>> from seroepi.dataset import Dataset
        >>> from seroepi.aggregate import Aggregator
        >>> from seroepi.estimators.hierarchical import HierarchicalMCMCEstimator
        >>> from seroepi.variables import Identifier, Spatial, Trait
        >>> ds = Dataset(
        ...     pl.DataFrame({"id": ["s1", "s2", "s3"], "region": ["A", "A", "B"], "k": ["K1", "K2", "K1"]}),
        ...     variables={"id": Identifier("id"), "region": Spatial("region"), "k": Trait("k", compositional=True)},
        ... )
        >>> agg = Aggregator(target=ds.var("k"), strata=[ds.var("region")], pad_zeros=True).fit(ds)
        >>> est = HierarchicalMCMCEstimator(num_warmup=50, num_samples=50, num_chains=1).fit(agg)
        >>> est.is_fitted_
        True
    """

    engine: str | None = "MCMC"
    estimate_method: str = "mcmc_nuts"
    _MCMC_CACHE: ClassVar[dict[tuple[Any, ...], MCMC]] = {}

    def __init__(
        self,
        num_samples: int = 1500,
        num_chains: int = 4,
        num_warmup: int = 1000,
        likelihood: Literal["binomial", "beta_binomial"] = "binomial",
        alpha: float = 0.05,
        chain_method: Literal["auto", "parallel", "vectorized", "sequential"] = "auto",
        target_accept_prob: float = 0.8,
        max_tree_depth: int = 10,
        init_strategy: Literal["median", "uniform", "sample"] | Callable[..., Any] = "median",
        random_state: int | None = 42,
        name: str | None = None,
    ) -> None:
        """Initializes the MCMC hierarchical Bayesian prevalence estimator.

        Args:
            num_samples: Number of posterior samples to draw per chain.
            num_chains: Number of parallel MCMC chains.
            num_warmup: Number of warmup/burn-in steps.
            likelihood: Binomial or overdispersed beta_binomial likelihood kernel.
            alpha: Significance level for credible intervals (default 0.05 for 95% CrIs).
            chain_method: MCMC chain execution strategy ('auto', 'parallel', 'vectorized', or 'sequential').
            target_accept_prob: NUTS target acceptance probability (default 0.8).
            max_tree_depth: NUTS maximum tree depth (default 10).
            init_strategy: Parameter initialization strategy ('median', 'uniform', 'sample', or custom callable).
            random_state: Random seed for reproducibility.
            name: Optional custom display name for the estimator.
        """
        self.num_samples = num_samples
        self.num_chains = num_chains
        self.num_warmup = num_warmup
        self.likelihood = likelihood
        self.alpha = alpha
        self.chain_method = chain_method
        self.target_accept_prob = target_accept_prob
        self.max_tree_depth = max_tree_depth
        self.init_strategy = init_strategy
        self.random_state = random_state
        self.name = name

    def _resolve_chain_method(self) -> str:
        """Determines the optimal chain execution method based on active JAX devices."""
        if self.chain_method != "auto":
            return self.chain_method
        if self.num_chains <= 1:
            return "sequential"
        if jax.device_count() >= self.num_chains:
            return "parallel"
        return "vectorized"

    def _resolve_init_strategy(self) -> tuple[Callable[..., Any], str | None]:
        """Resolves the NumPyro initialization function and its cacheable string key."""
        if callable(self.init_strategy):
            return self.init_strategy, None
        if self.init_strategy == "sample":
            return init_to_sample, "sample"
        if self.init_strategy == "uniform":
            return init_to_uniform, "uniform"
        return init_to_median, "median"

    def _get_or_create_mcmc(self, *, n_targets: int, n_cells: int, has_cell_idx: bool) -> MCMC:
        """Retrieves a cached compiled MCMC runner or creates a new one with jit_model_args=True."""
        resolved_chain_method = self._resolve_chain_method()
        init_strat, init_key = self._resolve_init_strategy()
        cache_key = (
            self.likelihood,
            n_targets,
            n_cells,
            has_cell_idx,
            self.num_warmup,
            self.num_samples,
            self.num_chains,
            resolved_chain_method,
            float(self.target_accept_prob),
            int(self.max_tree_depth),
            init_key,
        )
        if init_key is not None and cache_key in self._MCMC_CACHE:
            return self._MCMC_CACHE[cache_key]

        model_fn = self._get_model_fn(n_targets=n_targets, n_cells=n_cells)
        kernel = NUTS(
            model_fn,
            init_strategy=init_strat,
            target_accept_prob=self.target_accept_prob,
            max_tree_depth=self.max_tree_depth,
        )
        mcmc = MCMC(
            kernel,
            num_warmup=self.num_warmup,
            num_samples=self.num_samples,
            num_chains=self.num_chains,
            progress_bar=False,
            chain_method=resolved_chain_method,
            jit_model_args=True,
        )
        if init_key is not None:
            self._MCMC_CACHE[cache_key] = mcmc
        return mcmc

    def _run_inference(self, jax_data: dict[str, Any], *, n_targets: int, n_cells: int) -> None:
        prog = current_progress()
        seed = self.random_state if self.random_state is not None else 42
        rng_key = random.PRNGKey(seed)
        mcmc = self._get_or_create_mcmc(
            n_targets=n_targets,
            n_cells=n_cells,
            has_cell_idx="cell_idx" in jax_data,
        )
        prog.report(
            0.15,
            message=f"Fitting {self.display_name}",
            detail=f"Running NUTS ({self.num_warmup:,} warmup + {self.num_samples:,} samples × {self.num_chains} chains)...",
            force=True,
        )
        mcmc.run(rng_key, **jax_data)
        prog.report(
            0.86, message=f"Fitting {self.display_name}", detail="Extracting MCMC posterior samples...", force=True
        )
        self.samples_ = {k: np.asarray(v) for k, v in mcmc.get_samples().items()}
        self.extra_fields_ = {k: np.asarray(v) for k, v in mcmc.get_extra_fields().items()}

    def diagnostics(self) -> pl.DataFrame:
        """Returns MCMC convergence diagnostics (R-hat, ESS, mean, std) as a Polars ``DataFrame``.

        Returns:
            A Polars ``DataFrame`` with one row per model parameter and sampler diagnostic statistic.

        Raises:
            RuntimeError: If the estimator has not been fitted or no posterior samples are available.
        """
        self.check_is_fitted()
        if not self.samples_:
            raise RuntimeError("No posterior samples found.")

        summary_dict = diagnostics.summary(self.samples_, prob=0.95, group_by_chain=False)

        rows: list[dict[str, Any]] = []
        for param, stats_dict in summary_dict.items():
            np_stats = {k: np.asarray(v, dtype=float) for k, v in stats_dict.items()}
            param_shape = np_stats["mean"].shape

            if len(param_shape) == 0:
                row: dict[str, Any] = {"Parameter": param}
                row.update({k: float(v) for k, v in np_stats.items()})
                rows.append(row)
            else:
                it = np.nditer(np.empty(param_shape), flags=["multi_index"])
                for _ in it:
                    idx = it.multi_index
                    idx_str = ",".join(map(str, idx))
                    row = {"Parameter": f"{param}[{idx_str}]"}
                    row.update({k: float(v[idx]) for k, v in np_stats.items()})
                    rows.append(row)

        if self.extra_fields_ is not None:
            for field, values in self.extra_fields_.items():
                val_array = np.asarray(values, dtype=float)
                rows.append(
                    {
                        "Parameter": f"sampler_{field}",
                        "mean": float(np.mean(val_array)),
                        "std": float(np.std(val_array)),
                        "sum": float(np.sum(val_array)),
                    }
                )

        return pl.DataFrame(rows)

diagnostics

diagnostics() -> DataFrame

Returns MCMC convergence diagnostics (R-hat, ESS, mean, std) as a Polars DataFrame.

Returns:

Type Description
DataFrame

A Polars DataFrame with one row per model parameter and sampler diagnostic statistic.

Raises:

Type Description
RuntimeError

If the estimator has not been fitted or no posterior samples are available.

Source code in src/seroepi/estimators/hierarchical.py
def diagnostics(self) -> pl.DataFrame:
    """Returns MCMC convergence diagnostics (R-hat, ESS, mean, std) as a Polars ``DataFrame``.

    Returns:
        A Polars ``DataFrame`` with one row per model parameter and sampler diagnostic statistic.

    Raises:
        RuntimeError: If the estimator has not been fitted or no posterior samples are available.
    """
    self.check_is_fitted()
    if not self.samples_:
        raise RuntimeError("No posterior samples found.")

    summary_dict = diagnostics.summary(self.samples_, prob=0.95, group_by_chain=False)

    rows: list[dict[str, Any]] = []
    for param, stats_dict in summary_dict.items():
        np_stats = {k: np.asarray(v, dtype=float) for k, v in stats_dict.items()}
        param_shape = np_stats["mean"].shape

        if len(param_shape) == 0:
            row: dict[str, Any] = {"Parameter": param}
            row.update({k: float(v) for k, v in np_stats.items()})
            rows.append(row)
        else:
            it = np.nditer(np.empty(param_shape), flags=["multi_index"])
            for _ in it:
                idx = it.multi_index
                idx_str = ",".join(map(str, idx))
                row = {"Parameter": f"{param}[{idx_str}]"}
                row.update({k: float(v[idx]) for k, v in np_stats.items()})
                rows.append(row)

    if self.extra_fields_ is not None:
        for field, values in self.extra_fields_.items():
            val_array = np.asarray(values, dtype=float)
            rows.append(
                {
                    "Parameter": f"sampler_{field}",
                    "mean": float(np.mean(val_array)),
                    "std": float(np.std(val_array)),
                    "sum": float(np.sum(val_array)),
                }
            )

    return pl.DataFrame(rows)

HierarchicalSVIEstimator

Bases: BaseHierarchicalEstimator

Hierarchical Bayesian model for prevalence estimation fitted via fast Stochastic Variational Inference (SVI).

Uses an automatic variational guide (AutoNormal) and Adam optimization to achieve fast approximate posterior draws on large datasets without full MCMC simulation.

Parameters:

Name Type Description Default
num_steps int

Number of optimization steps for variational ELBO.

3000
learning_rate float

Step size for Adam optimizer.

0.01
num_samples int

Number of posterior predictive draws from the fitted guide.

1500
likelihood Literal['binomial', 'beta_binomial']

Binomial or overdispersed beta_binomial likelihood kernel.

'binomial'
alpha float

Significance level for credible intervals (default 0.05 for 95% CrIs).

0.05
random_state int | None

Random seed for reproducibility.

42
name str | None

Optional custom display name for the estimator.

None

Attributes:

Name Type Description
losses_ ndarray | None

1D NumPy array of variational ELBO loss values recorded at each optimization step.

samples_ dict[str, ndarray] | None

Dictionary mapping model parameter names to NumPy arrays of variational posterior draws.

estimates_ DataFrame | None

Polars DataFrame containing posterior mean prevalence and credible intervals.

Examples:

>>> import polars as pl
>>> from seroepi.dataset import Dataset
>>> from seroepi.aggregate import Aggregator
>>> from seroepi.estimators.hierarchical import HierarchicalSVIEstimator
>>> from seroepi.variables import Identifier, Spatial, Trait
>>> ds = Dataset(
...     pl.DataFrame({"id": ["s1", "s2", "s3"], "region": ["A", "A", "B"], "k": ["K1", "K2", "K1"]}),
...     variables={"id": Identifier("id"), "region": Spatial("region"), "k": Trait("k", compositional=True)},
... )
>>> agg = Aggregator(target=ds.var("k"), strata=[ds.var("region")], pad_zeros=True).fit(ds)
>>> est = HierarchicalSVIEstimator(num_steps=200, num_samples=100).fit(agg)
>>> est.diagnostics().columns
['step', 'elbo_loss']

Methods:

Name Description
diagnostics

Returns SVI optimization ELBO loss history as a formatted Polars DataFrame.

Source code in src/seroepi/estimators/hierarchical.py
class HierarchicalSVIEstimator(BaseHierarchicalEstimator):
    """Hierarchical Bayesian model for prevalence estimation fitted via fast Stochastic Variational Inference (SVI).

    Uses an automatic variational guide (`AutoNormal`) and Adam optimization to achieve fast approximate
    posterior draws on large datasets without full MCMC simulation.

    Args:
        num_steps: Number of optimization steps for variational ELBO.
        learning_rate: Step size for Adam optimizer.
        num_samples: Number of posterior predictive draws from the fitted guide.
        likelihood: Binomial or overdispersed beta_binomial likelihood kernel.
        alpha: Significance level for credible intervals (default 0.05 for 95% CrIs).
        random_state: Random seed for reproducibility.
        name: Optional custom display name for the estimator.

    Attributes:
        losses_: 1D NumPy array of variational ELBO loss values recorded at each optimization step.
        samples_: Dictionary mapping model parameter names to NumPy arrays of variational posterior draws.
        estimates_: Polars ``DataFrame`` containing posterior mean prevalence and credible intervals.

    Examples:
        >>> import polars as pl
        >>> from seroepi.dataset import Dataset
        >>> from seroepi.aggregate import Aggregator
        >>> from seroepi.estimators.hierarchical import HierarchicalSVIEstimator
        >>> from seroepi.variables import Identifier, Spatial, Trait
        >>> ds = Dataset(
        ...     pl.DataFrame({"id": ["s1", "s2", "s3"], "region": ["A", "A", "B"], "k": ["K1", "K2", "K1"]}),
        ...     variables={"id": Identifier("id"), "region": Spatial("region"), "k": Trait("k", compositional=True)},
        ... )
        >>> agg = Aggregator(target=ds.var("k"), strata=[ds.var("region")], pad_zeros=True).fit(ds)
        >>> est = HierarchicalSVIEstimator(num_steps=200, num_samples=100).fit(agg)
        >>> est.diagnostics().columns
        ['step', 'elbo_loss']
    """

    engine: str | None = "SVI"
    estimate_method: str = "svi"

    def __init__(
        self,
        num_steps: int = 3000,
        learning_rate: float = 0.01,
        num_samples: int = 1500,
        likelihood: Literal["binomial", "beta_binomial"] = "binomial",
        alpha: float = 0.05,
        random_state: int | None = 42,
        name: str | None = None,
    ) -> None:
        """Initializes the SVI hierarchical Bayesian prevalence estimator.

        Args:
            num_steps: Number of optimization steps for variational ELBO.
            learning_rate: Step size for Adam optimizer.
            num_samples: Number of posterior predictive draws from the fitted guide.
            likelihood: Binomial or overdispersed beta_binomial likelihood kernel.
            alpha: Significance level for credible intervals (default 0.05 for 95% CrIs).
            random_state: Random seed for reproducibility.
            name: Optional custom display name for the estimator.
        """
        self.num_steps = num_steps
        self.learning_rate = learning_rate
        self.num_samples = num_samples
        self.likelihood = likelihood
        self.alpha = alpha
        self.random_state = random_state
        self.name = name

    def _run_inference(self, jax_data: dict[str, Any], *, n_targets: int, n_cells: int) -> None:
        prog = current_progress()
        seed = self.random_state if self.random_state is not None else 42
        rng_key = random.PRNGKey(seed)
        opt_key, pred_key = random.split(rng_key)

        model_fn = self._get_model_fn(n_targets=n_targets, n_cells=n_cells)
        guide = autoguide.AutoNormal(model_fn)
        optimizer = optim.Adam(step_size=self.learning_rate)
        svi = SVI(model_fn, guide, optimizer, loss=Trace_ELBO())

        if not prog.is_active or self.num_steps <= 100:
            svi_result = svi.run(opt_key, num_steps=self.num_steps, progress_bar=False, **jax_data)
            self.losses_ = np.asarray(svi_result.losses, dtype=float)
            svi_params = svi_result.params
        else:
            prog.report(
                0.10, message=f"Fitting {self.display_name}", detail="Initializing variational guide...", force=True
            )
            svi_state = svi.init(opt_key, **jax_data)
            n_chunks = min(15, max(2, self.num_steps // 100))
            chunk_size = self.num_steps // n_chunks
            rem_steps = self.num_steps % n_chunks

            def _step_fn(state: Any, _: Any) -> tuple[Any, Any]:
                return svi.update(state, **jax_data)

            run_chunk = jax.jit(lambda st: jax.lax.scan(_step_fn, st, None, length=chunk_size))
            loss_parts: list[np.ndarray] = []
            done = 0
            for _ in range(n_chunks):
                svi_state, chunk_losses = run_chunk(svi_state)
                c_np = np.asarray(chunk_losses, dtype=float)
                loss_parts.append(c_np)
                done += chunk_size
                last_loss = float(c_np[-1]) if len(c_np) > 0 else 0.0
                prog.report(
                    0.10 + 0.76 * (done / self.num_steps),
                    message=f"Fitting {self.display_name}",
                    detail=f"Step {done:,}/{self.num_steps:,} (ELBO: {last_loss:,.1f})",
                )
            if rem_steps > 0:
                svi_state, rem_losses = jax.lax.scan(_step_fn, svi_state, None, length=rem_steps)
                loss_parts.append(np.asarray(rem_losses, dtype=float))
            self.losses_ = np.concatenate(loss_parts) if loss_parts else np.array([], dtype=float)
            svi_params = svi.get_params(svi_state)

        prog.report(
            0.88, message=f"Fitting {self.display_name}", detail=f"Sampling {self.num_samples:,} posterior draws..."
        )
        posterior_draws = guide.sample_posterior(
            pred_key,
            svi_params,
            sample_shape=(self.num_samples,),
        )
        self.samples_ = {k: np.asarray(v) for k, v in posterior_draws.items()}

    def diagnostics(self) -> pl.DataFrame:
        """Returns SVI optimization ELBO loss history as a formatted Polars ``DataFrame``.

        Returns:
            A Polars ``DataFrame`` with columns ``["step", "elbo_loss"]``.

        Raises:
            RuntimeError: If the estimator has not been fitted or no loss history is available.
        """
        self.check_is_fitted()
        if not hasattr(self, "losses_") or self.losses_ is None:
            raise RuntimeError("No optimization loss history found.")
        return pl.DataFrame(
            {
                "step": np.arange(len(self.losses_)),
                "elbo_loss": np.asarray(self.losses_, dtype=float),
            }
        )

diagnostics

diagnostics() -> DataFrame

Returns SVI optimization ELBO loss history as a formatted Polars DataFrame.

Returns:

Type Description
DataFrame

A Polars DataFrame with columns ["step", "elbo_loss"].

Raises:

Type Description
RuntimeError

If the estimator has not been fitted or no loss history is available.

Source code in src/seroepi/estimators/hierarchical.py
def diagnostics(self) -> pl.DataFrame:
    """Returns SVI optimization ELBO loss history as a formatted Polars ``DataFrame``.

    Returns:
        A Polars ``DataFrame`` with columns ``["step", "elbo_loss"]``.

    Raises:
        RuntimeError: If the estimator has not been fitted or no loss history is available.
    """
    self.check_is_fitted()
    if not hasattr(self, "losses_") or self.losses_ is None:
        raise RuntimeError("No optimization loss history found.")
    return pl.DataFrame(
        {
            "step": np.arange(len(self.losses_)),
            "elbo_loss": np.asarray(self.losses_, dtype=float),
        }
    )

Multi-Valent Formulation & Differential Contrasts (seroepi.estimators.formulation)

formulation

Scikit-learn meta-estimators for vaccine/cocktail formulation design, LOO stability, and model comparison.

Classes:

Name Description
Formulation

Scikit-learn meta-estimator for target formulation design and Leave-One-Stratum-Out stability.

FormulationComparison

Meta-estimator comparing target rankings, rank shifts, concordance, and differential prevalence across models or cohorts.

Formulation

Bases: BaseEstimator, RegressorMixin, MetaEstimatorMixin

Scikit-learn meta-estimator for target formulation design and Leave-One-Stratum-Out stability.

Wraps a single prevalence estimator to: 1. Rank target categories globally (1..K) using pooled or equal-stratum weighting. 2. Evaluate burden threshold exceedance (\(H_{0,j}: p_j \le \tau_{\min}\)) with Bayesian or BH FDR control. 3. Select the top-valency target formulation and export it as a binary Target. 4. Quantify sampling/geographic sensitivity via Leave-One-Stratum-Out (LOSO) or group cross-validation.

Parameters:

Name Type Description Default
estimator BaseEstimator

Base prevalence estimator instance to wrap.

required
valency int

Number of top-ranked target categories to include in the formulation.

6
weighting Literal['pooled', 'equal']

Stratum aggregation scheme ('pooled' for sample-size weighted or 'equal' for macro-averaged).

'pooled'
targets Sequence[str] | None

Optional explicit sequence of target names overriding top-valency selection.

None
cv Literal['loso'] | bool | BaseCrossValidator | None

Cross-validation strategy ('loso', False/None to disable, or a scikit-learn BaseCrossValidator).

'loso'
min_prevalence float

Minimum prevalence threshold for burden exceedance hypothesis testing.

0.0
alpha float

Significance level for credible/confidence intervals and FDR control.

0.05
name str | None

Optional custom display name for the formulation.

None

Attributes:

Name Type Description
is_fitted_ bool

Boolean flag indicating whether fit() has been called.

aggregator_ Aggregator

Fitted Aggregator bound during fit().

estimator_ BaseEstimator

Fitted BaseEstimator instance used to derive target rankings.

rankings_ DataFrame

Polars DataFrame of ranked targets with prevalence, cumulative coverage, and FDR metrics.

selected_targets_ list[str]

List of selected target category names in the formulation.

cv_rankings_ DataFrame

Polars DataFrame of per-fold target rankings across LOSO cross-validation splits.

stability_metrics_ DataFrame

Polars DataFrame summarizing per-target CV rank stability and selection frequency.

fold_summary_ DataFrame

Polars DataFrame summarizing Jaccard similarity and held-out coverage per CV fold.

warnings_ tuple[str, ...]

Tuple of warning messages captured from the underlying estimator.

Examples:

>>> import polars as pl
>>> from seroepi.dataset import Dataset
>>> from seroepi.aggregate import Aggregator
>>> from seroepi.estimators.unpooled import UnpooledEstimator
>>> from seroepi.estimators.formulation import Formulation
>>> from seroepi.variables import Identifier, Spatial, Trait
>>> ds = Dataset(
...     pl.DataFrame({"id": ["s1", "s2", "s3", "s4"], "site": ["A", "A", "B", "B"], "k": ["K1", "K2", "K1", "K3"]}),
...     variables={"id": Identifier("id"), "site": Spatial("site"), "k": Trait("k", compositional=True)},
... )
>>> agg = Aggregator(target=ds.var("k"), strata=[ds.var("site")]).fit(ds)
>>> form = Formulation(UnpooledEstimator(method="wilson"), valency=2, cv="loso").fit(agg)
>>> form.selected_targets_
['K1', 'K2']

Methods:

Name Description
check_is_fitted

Validates that the formulation has been fitted.

save

Serializes the fitted Formulation into a compressed Joblib file or in-memory bytes.

load

Loads a fitted Formulation from a Joblib file, path, or bytes buffer.

fit

Fits the underlying estimator on X (or reuses a pre-fitted estimator), ranks targets, and runs LOSO CV.

score_by_stratum

Computes observed and predicted formulation coverage per stratum.

stratum_trajectories

Computes step-by-step cumulative coverage trajectories across formulation-ranked targets per stratum.

predict

Predicts formulation coverage fraction for each stratum in X.

score

Computes negative sample-size-weighted Brier score of formulation coverage across strata in X.

to_target

Exports the formulated top-valency targets into a binary SeroEpi Target.

compare

Fits and returns a FormulationComparison contrasting this formulation with another.

Source code in src/seroepi/estimators/formulation.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
class Formulation(SklearnBaseEstimator, RegressorMixin, MetaEstimatorMixin):
    """Scikit-learn meta-estimator for target formulation design and Leave-One-Stratum-Out stability.

    Wraps a single prevalence estimator to:
    1. Rank target categories globally (1..K) using pooled or equal-stratum weighting.
    2. Evaluate burden threshold exceedance ($H_{0,j}: p_j \\le \\tau_{\\min}$) with Bayesian or BH FDR control.
    3. Select the top-``valency`` target formulation and export it as a binary ``Target``.
    4. Quantify sampling/geographic sensitivity via Leave-One-Stratum-Out (LOSO) or group cross-validation.

    Args:
        estimator: Base prevalence estimator instance to wrap.
        valency: Number of top-ranked target categories to include in the formulation.
        weighting: Stratum aggregation scheme ('pooled' for sample-size weighted or 'equal' for macro-averaged).
        targets: Optional explicit sequence of target names overriding top-valency selection.
        cv: Cross-validation strategy ('loso', False/None to disable, or a scikit-learn BaseCrossValidator).
        min_prevalence: Minimum prevalence threshold for burden exceedance hypothesis testing.
        alpha: Significance level for credible/confidence intervals and FDR control.
        name: Optional custom display name for the formulation.

    Attributes:
        is_fitted_: Boolean flag indicating whether ``fit()`` has been called.
        aggregator_: Fitted ``Aggregator`` bound during ``fit()``.
        estimator_: Fitted ``BaseEstimator`` instance used to derive target rankings.
        rankings_: Polars ``DataFrame`` of ranked targets with prevalence, cumulative coverage, and FDR metrics.
        selected_targets_: List of selected target category names in the formulation.
        cv_rankings_: Polars ``DataFrame`` of per-fold target rankings across LOSO cross-validation splits.
        stability_metrics_: Polars ``DataFrame`` summarizing per-target CV rank stability and selection frequency.
        fold_summary_: Polars ``DataFrame`` summarizing Jaccard similarity and held-out coverage per CV fold.
        warnings_: Tuple of warning messages captured from the underlying estimator.

    Examples:
        >>> import polars as pl
        >>> from seroepi.dataset import Dataset
        >>> from seroepi.aggregate import Aggregator
        >>> from seroepi.estimators.unpooled import UnpooledEstimator
        >>> from seroepi.estimators.formulation import Formulation
        >>> from seroepi.variables import Identifier, Spatial, Trait
        >>> ds = Dataset(
        ...     pl.DataFrame({"id": ["s1", "s2", "s3", "s4"], "site": ["A", "A", "B", "B"], "k": ["K1", "K2", "K1", "K3"]}),
        ...     variables={"id": Identifier("id"), "site": Spatial("site"), "k": Trait("k", compositional=True)},
        ... )
        >>> agg = Aggregator(target=ds.var("k"), strata=[ds.var("site")]).fit(ds)
        >>> form = Formulation(UnpooledEstimator(method="wilson"), valency=2, cv="loso").fit(agg)
        >>> form.selected_targets_
        ['K1', 'K2']
    """

    is_fitted_: bool = False
    aggregator_: Aggregator
    estimator_: BaseEstimator
    rankings_: pl.DataFrame
    selected_targets_: list[str]
    cv_rankings_: pl.DataFrame
    stability_metrics_: pl.DataFrame
    fold_summary_: pl.DataFrame
    warnings_: tuple[str, ...] = ()

    def __init__(
        self,
        estimator: BaseEstimator,
        valency: int = 6,
        weighting: Literal["pooled", "equal"] = "pooled",
        targets: Sequence[str] | None = None,
        cv: Literal["loso"] | bool | BaseCrossValidator | None = "loso",
        min_prevalence: float = 0.0,
        alpha: float = 0.05,
        name: str | None = None,
    ) -> None:
        """Initializes the Formulation meta-estimator.

        Args:
            estimator: Base prevalence estimator instance to wrap.
            valency: Number of top-ranked target categories to include in the formulation.
            weighting: Stratum aggregation scheme ('pooled' for sample-size weighted or 'equal' for macro-averaged).
            targets: Optional explicit sequence of target names overriding top-valency selection.
            cv: Cross-validation strategy ('loso', False/None to disable, or a scikit-learn BaseCrossValidator).
            min_prevalence: Minimum prevalence threshold for burden exceedance hypothesis testing.
            alpha: Significance level for credible/confidence intervals and FDR control.
            name: Optional custom display name for the formulation.

        Raises:
            TypeError: If ``estimator`` is not a ``BaseEstimator`` instance.
            ValueError: If ``valency < 1``, ``weighting`` is invalid, or ``min_prevalence`` / ``alpha`` are out of bounds.
        """
        if not isinstance(estimator, BaseEstimator):
            raise TypeError(
                f"Formulation requires a single BaseEstimator instance, got {type(estimator).__name__}. "
                "To compare multiple estimators, use FormulationComparison."
            )
        if valency < 1:
            raise ValueError(f"valency must be >= 1, got {valency}.")
        if weighting not in ("pooled", "equal"):
            raise ValueError(f"weighting must be 'pooled' or 'equal', got {weighting!r}.")
        if not (0.0 <= min_prevalence < 1.0):
            raise ValueError(f"min_prevalence must be in [0.0, 1.0), got {min_prevalence}.")
        if not (0.0 < alpha < 1.0):
            raise ValueError(f"alpha must be in (0.0, 1.0), got {alpha}.")
        self.estimator = estimator
        self.valency = valency
        self.weighting = weighting
        self.targets = targets
        self.cv = cv
        self.min_prevalence = min_prevalence
        self.alpha = alpha
        self.name = name

    @property
    def display_name(self) -> str:
        """Returns a human-readable label for this formulation configuration.

        Returns:
            Custom ``name`` if provided, otherwise ``"<estimator> (<valency>v, <weighting>)"``.
        """
        if self.name:
            return self.name
        return f"{self.estimator.display_name} ({self.valency}v, {self.weighting})"

    @property
    def summary_label(self) -> str:
        """Returns a concise target and valency summary for UI registries.

        Returns:
            Formatted summary string describing the target title, valency, and weighting.
        """
        if getattr(self, "is_fitted_", False) and getattr(self, "aggregator_", None) is not None:
            return f"{self.aggregator_.target_title} · {self.valency}v ({self.weighting})"
        return f"{self.valency}v ({self.weighting})"

    def check_is_fitted(self) -> None:
        """Validates that the formulation has been fitted.

        Raises:
            RuntimeError: If ``fit()`` has not been called on this instance.
        """
        if not getattr(self, "is_fitted_", False):
            raise RuntimeError(
                f"This {self.__class__.__name__} instance is not fitted yet. "
                "Call 'fit' with an Aggregator before using it."
            )

    def save(self, path: str | Path | None = None, compress: int = 3) -> bytes | Path:
        """Serializes the fitted ``Formulation`` into a compressed Joblib file or in-memory bytes.

        Args:
            path: Optional file path to write the Joblib archive to. If ``None``, returns serialized bytes.
            compress: Joblib compression level from ``0`` to ``9``.

        Returns:
            The destination ``Path`` if ``path`` was provided, or raw ``bytes`` otherwise.

        Raises:
            RuntimeError: If the formulation has not been fitted yet.
        """
        self.check_is_fitted()
        if path is not None:
            dest = Path(path)
            dest.parent.mkdir(parents=True, exist_ok=True)
            joblib.dump(self, dest, compress=compress)
            return dest
        buffer = io.BytesIO()
        joblib.dump(self, buffer, compress=compress)
        return buffer.getvalue()

    @classmethod
    def load(cls, source: str | Path | bytes | io.BytesIO) -> Self:
        """Loads a fitted ``Formulation`` from a Joblib file, path, or bytes buffer.

        Args:
            source: File path, ``bytes``, or ``io.BytesIO`` buffer containing a serialized ``Formulation``.

        Returns:
            The deserialized ``Formulation`` instance.

        Raises:
            FileNotFoundError: If a path is given and does not exist.
            TypeError: If ``source`` is an unsupported type or does not deserialize to ``cls``.
        """
        if isinstance(source, (str, Path)):
            src_path = Path(source)
            if not src_path.exists():
                raise FileNotFoundError(f"Joblib file not found: {src_path}")
            obj = joblib.load(src_path)
        elif isinstance(source, bytes):
            obj = joblib.load(io.BytesIO(source))
        elif isinstance(source, io.BytesIO):
            obj = joblib.load(source)
        else:
            raise TypeError(f"Unsupported source type for load: {type(source).__name__}")

        if not isinstance(obj, cls):
            raise TypeError(f"Expected deserialized object to be {cls.__name__}, got {type(obj).__name__}")
        return obj

    @staticmethod
    def _validate_aggregator(X: Aggregator) -> None:
        if not isinstance(X, Aggregator):
            raise TypeError(f"Formulation requires a fitted Aggregator instance, got {type(X).__name__}.")
        X.check_is_fitted()

    @staticmethod
    def _cumulative_bayesian_fdr(error_probs: np.ndarray) -> np.ndarray:
        """Computes cumulative Bayesian FDR (q-values) from local posterior error probabilities."""
        if len(error_probs) == 0:
            return np.array([], dtype=float)
        clipped = np.clip(np.asarray(error_probs, dtype=float), 0.0, 1.0)
        order = np.argsort(clipped, kind="stable")
        sorted_err = clipped[order]
        cum_mean = np.cumsum(sorted_err) / np.arange(1, len(sorted_err) + 1, dtype=float)
        q_vals = np.empty_like(cum_mean)
        q_vals[order] = np.clip(cum_mean, 0.0, 1.0)
        return q_vals

    @staticmethod
    def _compute_bayesian_threshold_fdr(
        draws: np.ndarray,
        min_prevalence: float,
        alpha: float,
    ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
        """Computes posterior error probability P(p_j <= min_prevalence), Bayesian FDR q_value, and significance."""
        if draws.shape[1] == 0:
            empty = np.array([], dtype=float)
            return empty, empty, np.array([], dtype=bool)
        pep = np.mean(draws <= min_prevalence, axis=0).astype(float)
        q_vals = Formulation._cumulative_bayesian_fdr(pep)
        return pep, q_vals, q_vals < alpha

    @staticmethod
    def _compute_frequentist_threshold_fdr(
        k: np.ndarray,
        n: np.ndarray,
        min_prevalence: float,
        alpha: float,
    ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
        """Computes one-sided exact binomial p-values for H0: p_j <= min_prevalence and Benjamini-Hochberg q-values."""
        if len(k) == 0:
            empty = np.array([], dtype=float)
            return empty, empty, np.array([], dtype=bool)
        p_vals = np.ones(len(k), dtype=float)
        for idx in range(len(k)):
            n_i = int(round(max(0.0, float(n[idx]))))
            k_i = int(round(np.clip(float(k[idx]), 0.0, float(n_i))))
            if n_i <= 0:
                p_vals[idx] = 1.0
            elif min_prevalence <= 0.0:
                p_vals[idx] = 0.0 if k_i > 0 else 1.0
            else:
                p_vals[idx] = float(stats.binomtest(k_i, n_i, p=min_prevalence, alternative="greater").pvalue)
        q_vals = np.asarray(stats.false_discovery_control(np.clip(p_vals, 0.0, 1.0), method="bh"), dtype=float)
        return p_vals, q_vals, q_vals < alpha

    def _build_rankings(self, est: BaseEstimator) -> tuple[pl.DataFrame, list[str]]:
        """Computes 1D target rankings (1..K), threshold FDR metrics, and selected top-valency targets."""
        target_col = est.target_col
        est_col = est.estimate.column
        summary = est.summarize_by_target(weighting=self.weighting)

        ranks = summary.with_columns(
            rank=pl.int_range(1, pl.len() + 1),
            cum_estimate=pl.col(est_col).cum_sum().clip(0.0, 1.0),
        )
        if self.targets is not None:
            selected = [str(t) for t in self.targets]
        else:
            selected = [str(t) for t in ranks.head(self.valency)[target_col].to_list()]

        rank_targets = ranks[target_col].cast(pl.String).to_list()
        draw_res = est.target_posterior_draws(weighting=self.weighting)
        if draw_res is not None and len(rank_targets) > 0:
            draw_targets, draws_mat = draw_res
            idx_map = {t: i for i, t in enumerate(draw_targets)}
            aligned = np.zeros((draws_mat.shape[0], len(rank_targets)), dtype=float)
            for col_i, t_name in enumerate(rank_targets):
                src_i = idx_map.get(t_name)
                if src_i is not None:
                    aligned[:, col_i] = draws_mat[:, src_i]
            p_or_pep, q_vals, sig = self._compute_bayesian_threshold_fdr(
                aligned,
                self.min_prevalence,
                self.alpha,
            )
        else:
            p_or_pep, q_vals, sig = self._compute_frequentist_threshold_fdr(
                ranks["event"].to_numpy().astype(float),
                ranks["n"].to_numpy().astype(float),
                self.min_prevalence,
                self.alpha,
            )

        ranks = ranks.with_columns(
            in_formulation=pl.col(target_col).cast(pl.String).is_in(selected),
            p_or_pep=pl.Series(p_or_pep, dtype=pl.Float64),
            q_value=pl.Series(q_vals, dtype=pl.Float64),
            significant=pl.Series(sig, dtype=pl.Boolean),
        )
        return ranks, selected

    def _coverage_by_stratum(
        self,
        X: Aggregator,
        estimator: BaseEstimator,
        selected_targets: Sequence[str],
    ) -> pl.DataFrame:
        """Computes observed and predicted formulation coverage for each stratum in X."""
        df = X.data_
        target_col = estimator.target_col
        preds = np.asarray(estimator.predict(X), dtype=float)
        active_set = [str(t) for t in selected_targets]
        in_form = pl.col(target_col).cast(pl.String).is_in(active_set)
        scored = df.with_columns(
            _pred=pl.Series(preds),
            _in_form=in_form,
        ).with_columns(
            _form_event=pl.when(pl.col("_in_form")).then(pl.col("event")).otherwise(0.0),
            _form_pred=pl.when(pl.col("_in_form")).then(pl.col("_pred")).otherwise(0.0),
        )
        strata_cols = list(X.strata_cols)
        if strata_cols:
            grouped = scored.group_by(strata_cols, maintain_order=True).agg(
                pl.col("n").max().alias("n"),
                pl.col("_form_event").sum().alias("_w_event"),
                pl.col("_form_pred").sum().alias("_w_pred"),
            )
        else:
            grouped = scored.select(
                pl.col("n").max().alias("n"),
                pl.col("_form_event").sum().alias("_w_event"),
                pl.col("_form_pred").sum().alias("_w_pred"),
            )

        return grouped.with_columns(
            observed_coverage=pl.when(pl.col("n") > 0)
            .then((pl.col("_w_event") / pl.col("n")).clip(0.0, 1.0))
            .otherwise(0.0),
            predicted_coverage=pl.col("_w_pred").clip(0.0, 1.0),
        ).drop(["_w_event", "_w_pred"])

    @classmethod
    def _from_fitted_estimator(
        cls,
        estimator: BaseEstimator,
        *,
        valency: int = 6,
        weighting: Literal["pooled", "equal"] = "pooled",
        targets: Sequence[str] | None = None,
        cv: Literal["loso"] | bool | BaseCrossValidator | None = None,
        min_prevalence: float = 0.0,
        alpha: float = 0.05,
        name: str | None = None,
    ) -> Self:
        """Wraps an already-fitted BaseEstimator into a fitted Formulation without re-running model inference."""
        estimator.check_is_fitted()
        if estimator.aggregator_ is None:
            raise ValueError("Fitted BaseEstimator must have a non-None `aggregator_` attribute.")
        instance = cls(
            estimator=estimator,
            valency=valency,
            weighting=weighting,
            targets=targets,
            cv=cv,
            min_prevalence=min_prevalence,
            alpha=alpha,
            name=name,
        )
        instance.aggregator_ = estimator.aggregator_
        instance.estimator_ = estimator
        instance.warnings_ = tuple(getattr(estimator, "warnings_", ()))
        instance.rankings_, instance.selected_targets_ = instance._build_rankings(estimator)
        if cv is not None and cv is not False and estimator.aggregator_.n_strata >= 1:
            instance._run_cv(estimator.aggregator_)
        else:
            instance.cv_rankings_ = pl.DataFrame()
            instance.stability_metrics_ = pl.DataFrame()
            instance.fold_summary_ = pl.DataFrame()
        instance.is_fitted_ = True
        return instance

    def fit(self, X: Aggregator | None = None, y: Any = None, sample_weight: Any = None) -> Self:
        """Fits the underlying estimator on ``X`` (or reuses a pre-fitted estimator), ranks targets, and runs LOSO CV.

        Args:
            X: Optional fitted ``Aggregator`` instance. May be ``None`` if ``self.estimator`` is already fitted.
            y: Ignored; present for scikit-learn compatibility.
            sample_weight: Optional sample weights forwarded to ``estimator.fit()``.

        Returns:
            The fitted ``Formulation`` instance (``self``).

        Raises:
            ValueError: If ``X`` is ``None`` and ``self.estimator`` is not already fitted.
            TypeError: If ``X`` is not a fitted ``Aggregator``.
        """
        prog = current_progress()
        est_is_fitted = (
            getattr(self.estimator, "is_fitted_", False) and getattr(self.estimator, "aggregator_", None) is not None
        )
        if X is None:
            if not est_is_fitted:
                raise ValueError("Formulation.fit(X=None) requires `self.estimator` to be already fitted.")
            eff_X = self.estimator.aggregator_
            assert eff_X is not None
        else:
            self._validate_aggregator(X)
            eff_X = X

        self.aggregator_ = eff_X
        will_run_cv = self.cv is not None and self.cv is not False and eff_X.n_strata >= 1
        fit_end = 0.15 if will_run_cv else 0.90

        if est_is_fitted and self.estimator.aggregator_ is eff_X and y is None and sample_weight is None:
            self.estimator_ = self.estimator
            prog.report(fit_end, message=f"Designing {self.display_name}", detail="Ranking targets...")
        else:
            with prog.subrange(0.0, fit_end, message=f"Designing {self.display_name}", detail_prefix="Full dataset"):
                self.estimator_ = clone(self.estimator).fit(eff_X, y=y, sample_weight=sample_weight)

        self.warnings_ = tuple(getattr(self.estimator_, "warnings_", ()))
        self.rankings_, self.selected_targets_ = self._build_rankings(self.estimator_)

        if will_run_cv:
            with prog.subrange(fit_end, 0.98, message=f"LOSO Cross-Validation ({self.display_name})"):
                self._run_cv(eff_X)
        else:
            self.cv_rankings_ = pl.DataFrame()
            self.stability_metrics_ = pl.DataFrame()
            self.fold_summary_ = pl.DataFrame()

        self.is_fitted_ = True
        prog.report(1.0, message=f"Fitted {self.display_name}", detail="Formulation ready")
        return self

    def _run_cv(self, X: Aggregator) -> None:
        """Executes Leave-One-Stratum-Out or group cross-validation across strata."""
        prog = current_progress()
        target_col = self.estimator_.target_col
        est_col = self.estimator_.estimate.column
        strata_cols = list(X.strata_cols)

        if len(strata_cols) == 1:
            groups = X.data_[strata_cols[0]].cast(pl.String).to_numpy()
        else:
            groups = (
                X.data_.select(pl.concat_str([pl.col(c).cast(pl.String) for c in strata_cols], separator="__"))
                .to_series()
                .to_numpy()
            )

        unique_groups = list(dict.fromkeys(groups))
        if len(unique_groups) < 2:
            self.cv_rankings_ = pl.DataFrame()
            self.stability_metrics_ = pl.DataFrame()
            self.fold_summary_ = pl.DataFrame()
            return

        splitter: BaseCrossValidator = self.cv if isinstance(self.cv, BaseCrossValidator) else LeaveOneGroupOut()
        baseline_map = self.rankings_.select(
            [target_col, pl.col("rank").alias("baseline_rank"), pl.col(est_col).alias("baseline_estimate")]
        )
        base_set = set(self.selected_targets_)

        splits = list(splitter.split(X.data_, groups=groups))
        n_folds = max(1, len(splits))

        fold_rank_frames: list[pl.DataFrame] = []
        fold_summaries: list[dict[str, Any]] = []

        for fold_idx, (train_idx, test_idx) in enumerate(splits):
            X_train = copy.copy(X)
            X_train.data_ = X.data_[train_idx]
            X_test = copy.copy(X)
            X_test.data_ = X.data_[test_idx]

            holdout_groups = list(dict.fromkeys(str(g) for g in groups[test_idx]))
            holdout_label = ", ".join(holdout_groups) if holdout_groups else str(fold_idx)

            f_start = fold_idx / n_folds
            f_end = (fold_idx + 1) / n_folds
            with prog.subrange(
                f_start,
                f_end,
                detail_prefix=f"Fold {fold_idx + 1}/{n_folds} ({holdout_label})",
            ) as fold_prog:
                fold_est = clone(self.estimator).fit(X_train)
                fold_ranks, fold_selected = self._build_rankings(fold_est)

                fold_df = (
                    fold_ranks.rename({"rank": "fold_rank", "in_formulation": "in_fold_formulation"})
                    .join(baseline_map, on=target_col, how="left")
                    .with_columns(
                        holdout_stratum=pl.lit(holdout_label),
                        rank_shift=pl.col("fold_rank") - pl.col("baseline_rank"),
                    )
                )
                fold_rank_frames.append(fold_df)

                fold_set = set(fold_selected)
                union_len = len(base_set | fold_set)
                jaccard = len(base_set & fold_set) / union_len if union_len > 0 else 1.0
                dropped = [t for t in self.selected_targets_ if t not in fold_set]
                added = [t for t in fold_selected if t not in base_set]

                cov_df = self._coverage_by_stratum(X_test, fold_est, fold_selected)
                total_n = int(cov_df["n"].sum())
                if total_n > 0:
                    obs_cov = float((cov_df["observed_coverage"] * cov_df["n"]).sum() / total_n)
                    pred_cov = float((cov_df["predicted_coverage"] * cov_df["n"]).sum() / total_n)
                else:
                    obs_cov = float(np.mean(cov_df["observed_coverage"].to_numpy())) if len(cov_df) > 0 else 0.0
                    pred_cov = float(np.mean(cov_df["predicted_coverage"].to_numpy())) if len(cov_df) > 0 else 0.0

                fold_summaries.append(
                    {
                        "holdout_stratum": holdout_label,
                        "n_isolates": total_n,
                        "jaccard_similarity": jaccard,
                        "dropped_targets": ", ".join(dropped),
                        "added_targets": ", ".join(added),
                        "heldout_observed_coverage": obs_cov,
                        "heldout_predicted_coverage": pred_cov,
                    }
                )
                fold_prog.report(1.0)

        self.cv_rankings_ = pl.concat(fold_rank_frames, how="diagonal_relaxed")
        self.fold_summary_ = pl.DataFrame(fold_summaries)
        self.stability_metrics_ = (
            self.cv_rankings_.group_by([target_col, "baseline_rank"], maintain_order=True)
            .agg(
                pl.col("fold_rank").mean().alias("mean_cv_rank"),
                pl.col("fold_rank").min().alias("min_cv_rank"),
                pl.col("fold_rank").max().alias("max_cv_rank"),
                pl.col("fold_rank").std().fill_null(0.0).alias("rank_std"),
                pl.col("rank_shift").abs().max().alias("max_abs_rank_shift"),
                pl.col("in_fold_formulation").cast(pl.Float64).mean().alias("selection_frequency"),
            )
            .sort("baseline_rank")
        )

    def score_by_stratum(self, X: Aggregator | None = None) -> pl.DataFrame:
        """Computes observed and predicted formulation coverage per stratum.

        Args:
            X: Optional fitted ``Aggregator`` to evaluate coverage on. Defaults to ``self.aggregator_``.

        Returns:
            A Polars ``DataFrame`` containing stratum columns, ``n``, ``observed_coverage``, and ``predicted_coverage``.

        Raises:
            RuntimeError: If the formulation has not been fitted yet.
            TypeError: If ``X`` is not a fitted ``Aggregator``.
        """
        self.check_is_fitted()
        eff_X = self.aggregator_ if X is None else X
        self._validate_aggregator(eff_X)
        return self._coverage_by_stratum(eff_X, self.estimator_, self.selected_targets_)

    def stratum_trajectories(
        self,
        *,
        top_n: int | None = None,
        max_strata: int | None = 8,
        other_label: str = "Other",
    ) -> tuple[pl.DataFrame, list[str]]:
        """Computes step-by-step cumulative coverage trajectories across formulation-ranked targets per stratum.

        Args:
            top_n: Optional maximum number of top-ranked targets to include along the trajectory.
            max_strata: Optional maximum number of strata before lumping remaining strata into ``other_label``.
            other_label: Display label for lumped tail strata.

        Returns:
            A 2-tuple ``(cum_df, ordered_targets)`` where ``cum_df`` contains ``cum_estimate``,
            ``cum_lower``, ``cum_upper``, ``rank``, and ``in_formulation`` columns per stratum and step.

        Raises:
            RuntimeError: If the formulation has not been fitted yet.
        """
        self.check_is_fitted()
        target_col = self.estimator_.target_col
        ordered_targets = self.rankings_[target_col].cast(pl.String).to_list()
        if top_n is not None and top_n > 0:
            ordered_targets = ordered_targets[:top_n]
        cum_df, eff_targets = self.estimator_.cumulative_coverage(
            targets=ordered_targets,
            max_strata=max_strata,
            other_label=other_label,
        )
        rank_meta = self.rankings_.select(
            pl.col(target_col).cast(pl.String).alias(target_col),
            pl.col("rank"),
            pl.col("in_formulation"),
        )
        cum_df = cum_df.with_columns(pl.col(target_col).cast(pl.String)).join(
            rank_meta,
            on=target_col,
            how="left",
            maintain_order="left",
        )
        return cum_df, eff_targets

    def predict(self, X: Aggregator) -> np.ndarray:
        """Predicts formulation coverage fraction for each stratum in ``X``.

        Args:
            X: Fitted ``Aggregator`` instance to predict stratum coverage on.

        Returns:
            A 1D float64 NumPy array of predicted coverage proportions in ``[0, 1]`` for each stratum.

        Raises:
            RuntimeError: If the formulation has not been fitted yet.
        """
        cov_df = self.score_by_stratum(X)
        return cov_df["predicted_coverage"].to_numpy().astype(float)

    def score(self, X: Aggregator, y: Any = None, sample_weight: Any = None) -> float:
        """Computes negative sample-size-weighted Brier score of formulation coverage across strata in ``X``.

        Args:
            X: Fitted ``Aggregator`` instance to evaluate.
            y: Optional ground-truth coverage array overriding ``observed_coverage``.
            sample_weight: Optional stratum weights overriding stratum sample sizes ``n``.

        Returns:
            Negative weighted mean squared error between observed and predicted stratum coverage.

        Raises:
            RuntimeError: If the formulation has not been fitted yet.
        """
        self.check_is_fitted()
        self._validate_aggregator(X)
        cov_df = self._coverage_by_stratum(X, self.estimator_, self.selected_targets_)

        y_pred = cov_df["predicted_coverage"].to_numpy().astype(float)
        y_true = np.asarray(y, dtype=float) if y is not None else cov_df["observed_coverage"].to_numpy().astype(float)
        w = (
            np.asarray(sample_weight, dtype=float)
            if sample_weight is not None
            else np.maximum(1.0, cov_df["n"].to_numpy().astype(float))
        )
        total_w = float(np.sum(w))
        if total_w <= 0.0:
            return 0.0
        return -float(np.sum(w * (y_true - y_pred) ** 2) / total_w)

    def to_target(self, name: str | None = None, title: str | None = None) -> Target:
        """Exports the formulated top-valency targets into a binary SeroEpi ``Target``.

        Args:
            name: Optional machine identifier for the exported ``Target``.
            title: Optional human-readable display title for the exported ``Target``.

        Returns:
            A binary (non-compositional) ``Target`` matching any isolate carrying one of ``selected_targets_``.

        Raises:
            RuntimeError: If the formulation has not been fitted yet.
        """
        self.check_is_fitted()
        coerced_source = Target.coerce(
            self.aggregator_.target,
            compositional=self.aggregator_.compositional,
            negative_indicator=self.aggregator_.negative_indicator,
        )
        escaped = "|".join(re.escape(t) for t in self.selected_targets_)
        pattern = f"^(?:{escaped})$" if escaped else "^$"

        resolved_name = name or f"{coerced_source.name}_{self.valency}v"
        resolved_title = title or f"{self.valency}-Valent {self.aggregator_.target_title}"
        return Target(
            name=resolved_name,
            criteria=[
                Criterion(
                    variable=crit.variable,
                    pattern=pattern,
                    equivalence=dict(crit.equivalence),
                    negative_indicator=crit.negative_indicator,
                )
                for crit in coerced_source.criteria
            ],
            conjunction=coerced_source.conjunction,
            compositional=False,
            sep=coerced_source.sep,
            title=resolved_title,
        )

    def compare(
        self,
        other: "Formulation | BaseEstimator",
        name: str | None = None,
        *,
        alpha: float | None = None,
        null_margin: float = 0.0,
    ) -> "FormulationComparison":
        """Fits and returns a ``FormulationComparison`` contrasting this formulation with another.

        Args:
            other: Another ``Formulation`` or ``BaseEstimator`` instance to compare against.
            name: Optional display label for ``other`` in the comparison tables.
            alpha: Optional significance level override for differential prevalence FDR control.
            null_margin: Minimum absolute prevalence difference margin ``>= 0.0`` for Bayesian LFSR testing.

        Returns:
            A fitted ``FormulationComparison`` instance.

        Raises:
            RuntimeError: If this formulation has not been fitted yet.
        """
        self.check_is_fitted()
        other_name = name or (other.display_name if isinstance(other, (Formulation, BaseEstimator)) else "Comparison")
        if other_name == self.display_name:
            other_name = f"{other_name} (2)"
        comp = FormulationComparison(
            formulations={self.display_name: self, other_name: other},
            valency=self.valency,
            weighting=self.weighting,
            cv=None,
            alpha=alpha if alpha is not None else self.alpha,
            null_margin=null_margin,
        )
        other_is_fitted = getattr(other, "is_fitted_", False) and getattr(other, "aggregator_", None) is not None
        return comp.fit(None if other_is_fitted else self.aggregator_)

display_name property

display_name: str

Returns a human-readable label for this formulation configuration.

Returns:

Type Description
str

Custom name if provided, otherwise "<estimator> (<valency>v, <weighting>)".

summary_label property

summary_label: str

Returns a concise target and valency summary for UI registries.

Returns:

Type Description
str

Formatted summary string describing the target title, valency, and weighting.

check_is_fitted

check_is_fitted() -> None

Validates that the formulation has been fitted.

Raises:

Type Description
RuntimeError

If fit() has not been called on this instance.

Source code in src/seroepi/estimators/formulation.py
def check_is_fitted(self) -> None:
    """Validates that the formulation has been fitted.

    Raises:
        RuntimeError: If ``fit()`` has not been called on this instance.
    """
    if not getattr(self, "is_fitted_", False):
        raise RuntimeError(
            f"This {self.__class__.__name__} instance is not fitted yet. "
            "Call 'fit' with an Aggregator before using it."
        )

save

save(
    path: str | Path | None = None, compress: int = 3
) -> bytes | Path

Serializes the fitted Formulation into a compressed Joblib file or in-memory bytes.

Parameters:

Name Type Description Default
path str | Path | None

Optional file path to write the Joblib archive to. If None, returns serialized bytes.

None
compress int

Joblib compression level from 0 to 9.

3

Returns:

Type Description
bytes | Path

The destination Path if path was provided, or raw bytes otherwise.

Raises:

Type Description
RuntimeError

If the formulation has not been fitted yet.

Source code in src/seroepi/estimators/formulation.py
def save(self, path: str | Path | None = None, compress: int = 3) -> bytes | Path:
    """Serializes the fitted ``Formulation`` into a compressed Joblib file or in-memory bytes.

    Args:
        path: Optional file path to write the Joblib archive to. If ``None``, returns serialized bytes.
        compress: Joblib compression level from ``0`` to ``9``.

    Returns:
        The destination ``Path`` if ``path`` was provided, or raw ``bytes`` otherwise.

    Raises:
        RuntimeError: If the formulation has not been fitted yet.
    """
    self.check_is_fitted()
    if path is not None:
        dest = Path(path)
        dest.parent.mkdir(parents=True, exist_ok=True)
        joblib.dump(self, dest, compress=compress)
        return dest
    buffer = io.BytesIO()
    joblib.dump(self, buffer, compress=compress)
    return buffer.getvalue()

load classmethod

load(source: str | Path | bytes | BytesIO) -> Self

Loads a fitted Formulation from a Joblib file, path, or bytes buffer.

Parameters:

Name Type Description Default
source str | Path | bytes | BytesIO

File path, bytes, or io.BytesIO buffer containing a serialized Formulation.

required

Returns:

Type Description
Self

The deserialized Formulation instance.

Raises:

Type Description
FileNotFoundError

If a path is given and does not exist.

TypeError

If source is an unsupported type or does not deserialize to cls.

Source code in src/seroepi/estimators/formulation.py
@classmethod
def load(cls, source: str | Path | bytes | io.BytesIO) -> Self:
    """Loads a fitted ``Formulation`` from a Joblib file, path, or bytes buffer.

    Args:
        source: File path, ``bytes``, or ``io.BytesIO`` buffer containing a serialized ``Formulation``.

    Returns:
        The deserialized ``Formulation`` instance.

    Raises:
        FileNotFoundError: If a path is given and does not exist.
        TypeError: If ``source`` is an unsupported type or does not deserialize to ``cls``.
    """
    if isinstance(source, (str, Path)):
        src_path = Path(source)
        if not src_path.exists():
            raise FileNotFoundError(f"Joblib file not found: {src_path}")
        obj = joblib.load(src_path)
    elif isinstance(source, bytes):
        obj = joblib.load(io.BytesIO(source))
    elif isinstance(source, io.BytesIO):
        obj = joblib.load(source)
    else:
        raise TypeError(f"Unsupported source type for load: {type(source).__name__}")

    if not isinstance(obj, cls):
        raise TypeError(f"Expected deserialized object to be {cls.__name__}, got {type(obj).__name__}")
    return obj

fit

fit(
    X: Aggregator | None = None,
    y: Any = None,
    sample_weight: Any = None,
) -> Self

Fits the underlying estimator on X (or reuses a pre-fitted estimator), ranks targets, and runs LOSO CV.

Parameters:

Name Type Description Default
X Aggregator | None

Optional fitted Aggregator instance. May be None if self.estimator is already fitted.

None
y Any

Ignored; present for scikit-learn compatibility.

None
sample_weight Any

Optional sample weights forwarded to estimator.fit().

None

Returns:

Type Description
Self

The fitted Formulation instance (self).

Raises:

Type Description
ValueError

If X is None and self.estimator is not already fitted.

TypeError

If X is not a fitted Aggregator.

Source code in src/seroepi/estimators/formulation.py
def fit(self, X: Aggregator | None = None, y: Any = None, sample_weight: Any = None) -> Self:
    """Fits the underlying estimator on ``X`` (or reuses a pre-fitted estimator), ranks targets, and runs LOSO CV.

    Args:
        X: Optional fitted ``Aggregator`` instance. May be ``None`` if ``self.estimator`` is already fitted.
        y: Ignored; present for scikit-learn compatibility.
        sample_weight: Optional sample weights forwarded to ``estimator.fit()``.

    Returns:
        The fitted ``Formulation`` instance (``self``).

    Raises:
        ValueError: If ``X`` is ``None`` and ``self.estimator`` is not already fitted.
        TypeError: If ``X`` is not a fitted ``Aggregator``.
    """
    prog = current_progress()
    est_is_fitted = (
        getattr(self.estimator, "is_fitted_", False) and getattr(self.estimator, "aggregator_", None) is not None
    )
    if X is None:
        if not est_is_fitted:
            raise ValueError("Formulation.fit(X=None) requires `self.estimator` to be already fitted.")
        eff_X = self.estimator.aggregator_
        assert eff_X is not None
    else:
        self._validate_aggregator(X)
        eff_X = X

    self.aggregator_ = eff_X
    will_run_cv = self.cv is not None and self.cv is not False and eff_X.n_strata >= 1
    fit_end = 0.15 if will_run_cv else 0.90

    if est_is_fitted and self.estimator.aggregator_ is eff_X and y is None and sample_weight is None:
        self.estimator_ = self.estimator
        prog.report(fit_end, message=f"Designing {self.display_name}", detail="Ranking targets...")
    else:
        with prog.subrange(0.0, fit_end, message=f"Designing {self.display_name}", detail_prefix="Full dataset"):
            self.estimator_ = clone(self.estimator).fit(eff_X, y=y, sample_weight=sample_weight)

    self.warnings_ = tuple(getattr(self.estimator_, "warnings_", ()))
    self.rankings_, self.selected_targets_ = self._build_rankings(self.estimator_)

    if will_run_cv:
        with prog.subrange(fit_end, 0.98, message=f"LOSO Cross-Validation ({self.display_name})"):
            self._run_cv(eff_X)
    else:
        self.cv_rankings_ = pl.DataFrame()
        self.stability_metrics_ = pl.DataFrame()
        self.fold_summary_ = pl.DataFrame()

    self.is_fitted_ = True
    prog.report(1.0, message=f"Fitted {self.display_name}", detail="Formulation ready")
    return self

score_by_stratum

score_by_stratum(X: Aggregator | None = None) -> DataFrame

Computes observed and predicted formulation coverage per stratum.

Parameters:

Name Type Description Default
X Aggregator | None

Optional fitted Aggregator to evaluate coverage on. Defaults to self.aggregator_.

None

Returns:

Type Description
DataFrame

A Polars DataFrame containing stratum columns, n, observed_coverage, and predicted_coverage.

Raises:

Type Description
RuntimeError

If the formulation has not been fitted yet.

TypeError

If X is not a fitted Aggregator.

Source code in src/seroepi/estimators/formulation.py
def score_by_stratum(self, X: Aggregator | None = None) -> pl.DataFrame:
    """Computes observed and predicted formulation coverage per stratum.

    Args:
        X: Optional fitted ``Aggregator`` to evaluate coverage on. Defaults to ``self.aggregator_``.

    Returns:
        A Polars ``DataFrame`` containing stratum columns, ``n``, ``observed_coverage``, and ``predicted_coverage``.

    Raises:
        RuntimeError: If the formulation has not been fitted yet.
        TypeError: If ``X`` is not a fitted ``Aggregator``.
    """
    self.check_is_fitted()
    eff_X = self.aggregator_ if X is None else X
    self._validate_aggregator(eff_X)
    return self._coverage_by_stratum(eff_X, self.estimator_, self.selected_targets_)

stratum_trajectories

stratum_trajectories(
    *,
    top_n: int | None = None,
    max_strata: int | None = 8,
    other_label: str = "Other",
) -> tuple[DataFrame, list[str]]

Computes step-by-step cumulative coverage trajectories across formulation-ranked targets per stratum.

Parameters:

Name Type Description Default
top_n int | None

Optional maximum number of top-ranked targets to include along the trajectory.

None
max_strata int | None

Optional maximum number of strata before lumping remaining strata into other_label.

8
other_label str

Display label for lumped tail strata.

'Other'

Returns:

Type Description
DataFrame

A 2-tuple (cum_df, ordered_targets) where cum_df contains cum_estimate,

list[str]

cum_lower, cum_upper, rank, and in_formulation columns per stratum and step.

Raises:

Type Description
RuntimeError

If the formulation has not been fitted yet.

Source code in src/seroepi/estimators/formulation.py
def stratum_trajectories(
    self,
    *,
    top_n: int | None = None,
    max_strata: int | None = 8,
    other_label: str = "Other",
) -> tuple[pl.DataFrame, list[str]]:
    """Computes step-by-step cumulative coverage trajectories across formulation-ranked targets per stratum.

    Args:
        top_n: Optional maximum number of top-ranked targets to include along the trajectory.
        max_strata: Optional maximum number of strata before lumping remaining strata into ``other_label``.
        other_label: Display label for lumped tail strata.

    Returns:
        A 2-tuple ``(cum_df, ordered_targets)`` where ``cum_df`` contains ``cum_estimate``,
        ``cum_lower``, ``cum_upper``, ``rank``, and ``in_formulation`` columns per stratum and step.

    Raises:
        RuntimeError: If the formulation has not been fitted yet.
    """
    self.check_is_fitted()
    target_col = self.estimator_.target_col
    ordered_targets = self.rankings_[target_col].cast(pl.String).to_list()
    if top_n is not None and top_n > 0:
        ordered_targets = ordered_targets[:top_n]
    cum_df, eff_targets = self.estimator_.cumulative_coverage(
        targets=ordered_targets,
        max_strata=max_strata,
        other_label=other_label,
    )
    rank_meta = self.rankings_.select(
        pl.col(target_col).cast(pl.String).alias(target_col),
        pl.col("rank"),
        pl.col("in_formulation"),
    )
    cum_df = cum_df.with_columns(pl.col(target_col).cast(pl.String)).join(
        rank_meta,
        on=target_col,
        how="left",
        maintain_order="left",
    )
    return cum_df, eff_targets

predict

predict(X: Aggregator) -> ndarray

Predicts formulation coverage fraction for each stratum in X.

Parameters:

Name Type Description Default
X Aggregator

Fitted Aggregator instance to predict stratum coverage on.

required

Returns:

Type Description
ndarray

A 1D float64 NumPy array of predicted coverage proportions in [0, 1] for each stratum.

Raises:

Type Description
RuntimeError

If the formulation has not been fitted yet.

Source code in src/seroepi/estimators/formulation.py
def predict(self, X: Aggregator) -> np.ndarray:
    """Predicts formulation coverage fraction for each stratum in ``X``.

    Args:
        X: Fitted ``Aggregator`` instance to predict stratum coverage on.

    Returns:
        A 1D float64 NumPy array of predicted coverage proportions in ``[0, 1]`` for each stratum.

    Raises:
        RuntimeError: If the formulation has not been fitted yet.
    """
    cov_df = self.score_by_stratum(X)
    return cov_df["predicted_coverage"].to_numpy().astype(float)

score

score(
    X: Aggregator, y: Any = None, sample_weight: Any = None
) -> float

Computes negative sample-size-weighted Brier score of formulation coverage across strata in X.

Parameters:

Name Type Description Default
X Aggregator

Fitted Aggregator instance to evaluate.

required
y Any

Optional ground-truth coverage array overriding observed_coverage.

None
sample_weight Any

Optional stratum weights overriding stratum sample sizes n.

None

Returns:

Type Description
float

Negative weighted mean squared error between observed and predicted stratum coverage.

Raises:

Type Description
RuntimeError

If the formulation has not been fitted yet.

Source code in src/seroepi/estimators/formulation.py
def score(self, X: Aggregator, y: Any = None, sample_weight: Any = None) -> float:
    """Computes negative sample-size-weighted Brier score of formulation coverage across strata in ``X``.

    Args:
        X: Fitted ``Aggregator`` instance to evaluate.
        y: Optional ground-truth coverage array overriding ``observed_coverage``.
        sample_weight: Optional stratum weights overriding stratum sample sizes ``n``.

    Returns:
        Negative weighted mean squared error between observed and predicted stratum coverage.

    Raises:
        RuntimeError: If the formulation has not been fitted yet.
    """
    self.check_is_fitted()
    self._validate_aggregator(X)
    cov_df = self._coverage_by_stratum(X, self.estimator_, self.selected_targets_)

    y_pred = cov_df["predicted_coverage"].to_numpy().astype(float)
    y_true = np.asarray(y, dtype=float) if y is not None else cov_df["observed_coverage"].to_numpy().astype(float)
    w = (
        np.asarray(sample_weight, dtype=float)
        if sample_weight is not None
        else np.maximum(1.0, cov_df["n"].to_numpy().astype(float))
    )
    total_w = float(np.sum(w))
    if total_w <= 0.0:
        return 0.0
    return -float(np.sum(w * (y_true - y_pred) ** 2) / total_w)

to_target

to_target(
    name: str | None = None, title: str | None = None
) -> Target

Exports the formulated top-valency targets into a binary SeroEpi Target.

Parameters:

Name Type Description Default
name str | None

Optional machine identifier for the exported Target.

None
title str | None

Optional human-readable display title for the exported Target.

None

Returns:

Type Description
Target

A binary (non-compositional) Target matching any isolate carrying one of selected_targets_.

Raises:

Type Description
RuntimeError

If the formulation has not been fitted yet.

Source code in src/seroepi/estimators/formulation.py
def to_target(self, name: str | None = None, title: str | None = None) -> Target:
    """Exports the formulated top-valency targets into a binary SeroEpi ``Target``.

    Args:
        name: Optional machine identifier for the exported ``Target``.
        title: Optional human-readable display title for the exported ``Target``.

    Returns:
        A binary (non-compositional) ``Target`` matching any isolate carrying one of ``selected_targets_``.

    Raises:
        RuntimeError: If the formulation has not been fitted yet.
    """
    self.check_is_fitted()
    coerced_source = Target.coerce(
        self.aggregator_.target,
        compositional=self.aggregator_.compositional,
        negative_indicator=self.aggregator_.negative_indicator,
    )
    escaped = "|".join(re.escape(t) for t in self.selected_targets_)
    pattern = f"^(?:{escaped})$" if escaped else "^$"

    resolved_name = name or f"{coerced_source.name}_{self.valency}v"
    resolved_title = title or f"{self.valency}-Valent {self.aggregator_.target_title}"
    return Target(
        name=resolved_name,
        criteria=[
            Criterion(
                variable=crit.variable,
                pattern=pattern,
                equivalence=dict(crit.equivalence),
                negative_indicator=crit.negative_indicator,
            )
            for crit in coerced_source.criteria
        ],
        conjunction=coerced_source.conjunction,
        compositional=False,
        sep=coerced_source.sep,
        title=resolved_title,
    )

compare

compare(
    other: Formulation | BaseEstimator,
    name: str | None = None,
    *,
    alpha: float | None = None,
    null_margin: float = 0.0,
) -> FormulationComparison

Fits and returns a FormulationComparison contrasting this formulation with another.

Parameters:

Name Type Description Default
other Formulation | BaseEstimator

Another Formulation or BaseEstimator instance to compare against.

required
name str | None

Optional display label for other in the comparison tables.

None
alpha float | None

Optional significance level override for differential prevalence FDR control.

None
null_margin float

Minimum absolute prevalence difference margin >= 0.0 for Bayesian LFSR testing.

0.0

Returns:

Type Description
FormulationComparison

A fitted FormulationComparison instance.

Raises:

Type Description
RuntimeError

If this formulation has not been fitted yet.

Source code in src/seroepi/estimators/formulation.py
def compare(
    self,
    other: "Formulation | BaseEstimator",
    name: str | None = None,
    *,
    alpha: float | None = None,
    null_margin: float = 0.0,
) -> "FormulationComparison":
    """Fits and returns a ``FormulationComparison`` contrasting this formulation with another.

    Args:
        other: Another ``Formulation`` or ``BaseEstimator`` instance to compare against.
        name: Optional display label for ``other`` in the comparison tables.
        alpha: Optional significance level override for differential prevalence FDR control.
        null_margin: Minimum absolute prevalence difference margin ``>= 0.0`` for Bayesian LFSR testing.

    Returns:
        A fitted ``FormulationComparison`` instance.

    Raises:
        RuntimeError: If this formulation has not been fitted yet.
    """
    self.check_is_fitted()
    other_name = name or (other.display_name if isinstance(other, (Formulation, BaseEstimator)) else "Comparison")
    if other_name == self.display_name:
        other_name = f"{other_name} (2)"
    comp = FormulationComparison(
        formulations={self.display_name: self, other_name: other},
        valency=self.valency,
        weighting=self.weighting,
        cv=None,
        alpha=alpha if alpha is not None else self.alpha,
        null_margin=null_margin,
    )
    other_is_fitted = getattr(other, "is_fitted_", False) and getattr(other, "aggregator_", None) is not None
    return comp.fit(None if other_is_fitted else self.aggregator_)

FormulationComparison

Bases: BaseEstimator, MetaEstimatorMixin

Meta-estimator comparing target rankings, rank shifts, concordance, and differential prevalence across models or cohorts.

Parameters:

Name Type Description Default
formulations Mapping[str, Formulation | BaseEstimator]

Mapping from model/cohort labels to Formulation or BaseEstimator instances (at least 2).

required
valency int

Default target valency applied when wrapping raw BaseEstimator entries.

6
weighting Literal['pooled', 'equal']

Default stratum weighting ('pooled' or 'equal') applied when wrapping raw BaseEstimator entries.

'pooled'
cv Literal['loso'] | bool | BaseCrossValidator | None

Optional cross-validation strategy applied when fitting raw BaseEstimator entries.

None
alpha float

Significance level for differential prevalence intervals and FDR control.

0.05
null_margin float

Minimum absolute prevalence difference margin >= 0.0 for Bayesian LFSR testing.

0.0

Attributes:

Name Type Description
is_fitted_ bool

Boolean flag indicating whether fit() has been called.

aggregator_ Aggregator

Primary Aggregator bound during fit().

formulations_ dict[str, Formulation]

Dictionary mapping each label to its fitted Formulation instance.

rankings_ DataFrame

Stacked Polars DataFrame of rankings across all compared formulations.

rank_shifts_ DataFrame

Wide Polars DataFrame comparing per-target ranks and rank ranges across formulations.

concordance_ DataFrame

Polars DataFrame of pairwise Spearman rho, Kendall tau, and Jaccard top-V overlap.

differential_prevalence_ DataFrame

Polars DataFrame of pairwise target prevalence differences, fold changes, and FDR q-values.

Examples:

>>> import polars as pl
>>> from seroepi.dataset import Dataset
>>> from seroepi.aggregate import Aggregator
>>> from seroepi.estimators.unpooled import UnpooledEstimator
>>> from seroepi.estimators.formulation import FormulationComparison
>>> from seroepi.variables import Identifier, Spatial, Trait
>>> ds = Dataset(
...     pl.DataFrame({"id": ["s1", "s2", "s3", "s4"], "site": ["A", "A", "B", "B"], "k": ["K1", "K2", "K1", "K3"]}),
...     variables={"id": Identifier("id"), "site": Spatial("site"), "k": Trait("k", compositional=True)},
... )
>>> agg = Aggregator(target=ds.var("k"), strata=[ds.var("site")]).fit(ds)
>>> comp = FormulationComparison.from_strata(UnpooledEstimator(method="wilson"), agg, valency=2)
>>> comp.is_fitted_
True

Methods:

Name Description
check_is_fitted

Validates that the formulation comparison has been fitted.

from_strata

Slices a stratified Aggregator across strata into cohort Formulation objects and fits a comparison.

fit

Fits constituent formulations (or preserves pre-fitted cohort formulations) and computes comparisons.

contrast

Computes per-target differential prevalence, fold change, rank shifts, and FDR between two formulations.

Source code in src/seroepi/estimators/formulation.py
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
class FormulationComparison(SklearnBaseEstimator, MetaEstimatorMixin):
    """Meta-estimator comparing target rankings, rank shifts, concordance, and differential prevalence across models or cohorts.

    Args:
        formulations: Mapping from model/cohort labels to ``Formulation`` or ``BaseEstimator`` instances (at least 2).
        valency: Default target valency applied when wrapping raw ``BaseEstimator`` entries.
        weighting: Default stratum weighting ('pooled' or 'equal') applied when wrapping raw ``BaseEstimator`` entries.
        cv: Optional cross-validation strategy applied when fitting raw ``BaseEstimator`` entries.
        alpha: Significance level for differential prevalence intervals and FDR control.
        null_margin: Minimum absolute prevalence difference margin ``>= 0.0`` for Bayesian LFSR testing.

    Attributes:
        is_fitted_: Boolean flag indicating whether ``fit()`` has been called.
        aggregator_: Primary ``Aggregator`` bound during ``fit()``.
        formulations_: Dictionary mapping each label to its fitted ``Formulation`` instance.
        rankings_: Stacked Polars ``DataFrame`` of rankings across all compared formulations.
        rank_shifts_: Wide Polars ``DataFrame`` comparing per-target ranks and rank ranges across formulations.
        concordance_: Polars ``DataFrame`` of pairwise Spearman rho, Kendall tau, and Jaccard top-V overlap.
        differential_prevalence_: Polars ``DataFrame`` of pairwise target prevalence differences, fold changes, and FDR q-values.

    Examples:
        >>> import polars as pl
        >>> from seroepi.dataset import Dataset
        >>> from seroepi.aggregate import Aggregator
        >>> from seroepi.estimators.unpooled import UnpooledEstimator
        >>> from seroepi.estimators.formulation import FormulationComparison
        >>> from seroepi.variables import Identifier, Spatial, Trait
        >>> ds = Dataset(
        ...     pl.DataFrame({"id": ["s1", "s2", "s3", "s4"], "site": ["A", "A", "B", "B"], "k": ["K1", "K2", "K1", "K3"]}),
        ...     variables={"id": Identifier("id"), "site": Spatial("site"), "k": Trait("k", compositional=True)},
        ... )
        >>> agg = Aggregator(target=ds.var("k"), strata=[ds.var("site")]).fit(ds)
        >>> comp = FormulationComparison.from_strata(UnpooledEstimator(method="wilson"), agg, valency=2)
        >>> comp.is_fitted_
        True
    """

    is_fitted_: bool = False
    aggregator_: Aggregator
    formulations_: dict[str, Formulation]
    rankings_: pl.DataFrame
    rank_shifts_: pl.DataFrame
    concordance_: pl.DataFrame
    differential_prevalence_: pl.DataFrame

    def __init__(
        self,
        formulations: Mapping[str, Formulation | BaseEstimator],
        valency: int = 6,
        weighting: Literal["pooled", "equal"] = "pooled",
        cv: Literal["loso"] | bool | BaseCrossValidator | None = None,
        alpha: float = 0.05,
        null_margin: float = 0.0,
    ) -> None:
        """Initializes the FormulationComparison meta-estimator.

        Args:
            formulations: Mapping from model/cohort labels to ``Formulation`` or ``BaseEstimator`` instances (at least 2).
            valency: Default target valency applied when wrapping raw ``BaseEstimator`` entries.
            weighting: Default stratum weighting ('pooled' or 'equal') applied when wrapping raw ``BaseEstimator`` entries.
            cv: Optional cross-validation strategy applied when fitting raw ``BaseEstimator`` entries.
            alpha: Significance level for differential prevalence intervals and FDR control.
            null_margin: Minimum absolute prevalence difference margin ``>= 0.0`` for Bayesian LFSR testing.

        Raises:
            ValueError: If fewer than 2 formulations are provided, or if ``alpha`` or ``null_margin`` are invalid.
        """
        if len(formulations) < 2:
            raise ValueError(f"FormulationComparison requires at least 2 formulations, got {len(formulations)}.")
        if not (0.0 < alpha < 1.0):
            raise ValueError(f"alpha must be in (0.0, 1.0), got {alpha}.")
        if null_margin < 0.0:
            raise ValueError(f"null_margin must be >= 0.0, got {null_margin}.")
        self.formulations = formulations
        self.valency = valency
        self.weighting = weighting
        self.cv = cv
        self.alpha = alpha
        self.null_margin = null_margin

    def check_is_fitted(self) -> None:
        """Validates that the formulation comparison has been fitted.

        Raises:
            RuntimeError: If ``fit()`` has not been called on this instance.
        """
        if not getattr(self, "is_fitted_", False):
            raise RuntimeError(
                f"This {self.__class__.__name__} instance is not fitted yet. "
                "Call 'fit' with an Aggregator before using it."
            )

    @classmethod
    def from_strata(
        cls,
        estimator: BaseEstimator,
        X: Aggregator,
        strata_values: Sequence[str] | None = None,
        *,
        stratum_col: str | None = None,
        valency: int = 6,
        weighting: Literal["pooled", "equal"] = "pooled",
        alpha: float = 0.05,
        null_margin: float = 0.0,
    ) -> Self:
        """Slices a stratified ``Aggregator`` across strata into cohort ``Formulation`` objects and fits a comparison.

        Args:
            estimator: Unfitted or fitted ``BaseEstimator`` template cloned for each stratum cohort.
            X: Stratified fitted ``Aggregator`` instance.
            strata_values: Optional subset of stratum values to compare (requires at least 2).
            stratum_col: Optional stratum column name when ``X`` has multiple strata.
            valency: Target formulation valency for each cohort.
            weighting: Stratum weighting scheme (``"pooled"`` or ``"equal"``).
            alpha: Significance level for differential prevalence intervals and FDR control.
            null_margin: Minimum absolute prevalence difference margin ``>= 0.0`` for Bayesian LFSR testing.

        Returns:
            A fitted ``FormulationComparison`` instance contrasting the selected strata cohorts.

        Raises:
            ValueError: If ``X`` is unstratified, ``stratum_col`` is missing, or fewer than 2 strata values are selected.
        """
        Formulation._validate_aggregator(X)
        if X.n_strata == 0:
            raise ValueError("FormulationComparison.from_strata requires a stratified Aggregator (got 0 strata).")
        col = stratum_col or (X.single_stratum_col if X.is_single_stratum else X.strata_cols[0])
        if col not in X.data_.columns:
            raise ValueError(f"Stratum column {col!r} not found in Aggregator columns: {X.data_.columns}.")

        available_vals = [str(v) for v in X.data_[col].cast(pl.String).unique(maintain_order=True).to_list()]
        selected_vals = [str(v) for v in strata_values] if strata_values is not None else available_vals
        if len(selected_vals) < 2:
            raise ValueError(
                f"FormulationComparison.from_strata requires at least 2 strata values, got {selected_vals!r}."
            )

        prog = current_progress()
        n_cohorts = max(1, len(selected_vals))
        cohort_forms: dict[str, Formulation] = {}
        for idx, s_val in enumerate(selected_vals):
            cohort_agg = X.filter(**{col: s_val})
            c_start = 0.85 * (idx / n_cohorts)
            c_end = 0.85 * ((idx + 1) / n_cohorts)
            with prog.subrange(
                c_start,
                c_end,
                message="Comparing Cohort Formulations",
                detail_prefix=f"Cohort {idx + 1}/{n_cohorts} ({s_val})",
            ):
                cohort_forms[s_val] = Formulation(
                    estimator=clone(estimator),
                    valency=valency,
                    weighting=weighting,
                    cv=None,
                    alpha=alpha,
                    name=s_val,
                ).fit(cohort_agg)

        comp = cls(
            formulations=cohort_forms,
            valency=valency,
            weighting=weighting,
            cv=None,
            alpha=alpha,
            null_margin=null_margin,
        )
        with prog.subrange(0.85, 1.0, message="Computing Differential Prevalence & FDR"):
            return comp.fit(X=None)

    def fit(self, X: Aggregator | None = None, y: Any = None) -> Self:
        """Fits constituent formulations (or preserves pre-fitted cohort formulations) and computes comparisons.

        Args:
            X: Optional fitted ``Aggregator`` instance used to fit any unfitted constituent formulations.
            y: Ignored; present for scikit-learn compatibility.

        Returns:
            The fitted ``FormulationComparison`` instance (``self``).

        Raises:
            ValueError: If any constituent formulation/estimator is unfitted and ``X`` is ``None``.
            TypeError: If any constituent entry is not a ``Formulation`` or ``BaseEstimator``.
        """
        prog = current_progress()
        if X is not None:
            Formulation._validate_aggregator(X)

        fitted_forms: dict[str, Formulation] = {}
        rank_frames: list[pl.DataFrame] = []
        items = list(self.formulations.items())
        n_items = max(1, len(items))

        for idx, (label, spec) in enumerate(items):
            s_start = 0.80 * (idx / n_items)
            s_end = 0.80 * ((idx + 1) / n_items)
            with prog.subrange(
                s_start,
                s_end,
                message="Fitting Formulation Comparison",
                detail_prefix=f"Model {idx + 1}/{n_items} ({label})",
            ) as sub_prog:
                if isinstance(spec, Formulation):
                    if getattr(spec, "is_fitted_", False) and getattr(spec, "aggregator_", None) is not None:
                        form = spec
                    elif X is not None:
                        form = clone(spec).fit(X, y=y)
                    else:
                        raise ValueError(
                            f"Formulation {label!r} is not fitted and X=None was passed to FormulationComparison.fit()."
                        )
                elif isinstance(spec, BaseEstimator):
                    if getattr(spec, "is_fitted_", False) and getattr(spec, "aggregator_", None) is not None:
                        form = Formulation._from_fitted_estimator(
                            spec,
                            valency=self.valency,
                            weighting=self.weighting,
                            cv=self.cv,
                            alpha=self.alpha,
                            name=label,
                        )
                    elif X is not None:
                        form = Formulation(
                            estimator=spec,
                            valency=self.valency,
                            weighting=self.weighting,
                            cv=self.cv,
                            alpha=self.alpha,
                            name=label,
                        ).fit(X, y=y)
                    else:
                        raise ValueError(
                            f"Estimator {label!r} is not fitted and X=None was passed to FormulationComparison.fit()."
                        )
                else:
                    raise TypeError(
                        f"Formulation entry {label!r} must be a Formulation or BaseEstimator, got {type(spec).__name__}."
                    )
                sub_prog.report(1.0)

            fitted_forms[label] = form
            rank_frames.append(form.rankings_.with_columns(formulation=pl.lit(label)))

        self.aggregator_ = X if X is not None else next(iter(fitted_forms.values())).aggregator_
        self.formulations_ = fitted_forms
        self.rankings_ = pl.concat(rank_frames, how="diagonal_relaxed")
        prog.report(
            0.88, message="Computing Formulation Comparison", detail="Evaluating rank shifts and concordance..."
        )
        self._compute_rank_shifts_and_concordance()
        prog.report(0.94, message="Computing Formulation Comparison", detail="Testing differential prevalence & FDR...")
        self._compute_all_differential_prevalence()
        self.is_fitted_ = True
        prog.report(1.0, message="Formulation Comparison Complete", detail="Ready")
        return self

    @staticmethod
    def _compute_bayesian_differential(
        draws_a: np.ndarray,
        draws_b: np.ndarray,
        alpha: float,
        null_margin: float,
    ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
        """Computes draw-wise difference intervals, Local False Sign Rate (lfsr), and Bayesian FDR q_value."""
        n_draws = min(draws_a.shape[0], draws_b.shape[0])
        delta_draws = draws_b[:n_draws] - draws_a[:n_draws]
        q_low = 100.0 * (alpha / 2.0)
        q_high = 100.0 * (1.0 - alpha / 2.0)
        delta_lower, delta_upper = np.percentile(delta_draws, [q_low, q_high], axis=0)

        prob_le = np.mean(delta_draws <= null_margin, axis=0)
        prob_ge = np.mean(delta_draws >= -null_margin, axis=0)
        lfsr = np.minimum(prob_le, prob_ge).astype(float)
        q_vals = Formulation._cumulative_bayesian_fdr(lfsr)
        return delta_lower, delta_upper, lfsr, q_vals

    @staticmethod
    def _compute_frequentist_differential(
        k_a: np.ndarray,
        n_a: np.ndarray,
        est_a: np.ndarray,
        low_a: np.ndarray,
        high_a: np.ndarray,
        k_b: np.ndarray,
        n_b: np.ndarray,
        est_b: np.ndarray,
        low_b: np.ndarray,
        high_b: np.ndarray,
    ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
        """Computes Newcombe score difference intervals, two-sided Fisher's Exact p-values, and BH FDR q_values."""
        delta_est = est_b - est_a
        delta_lower = np.clip(
            delta_est - np.sqrt(np.square(est_b - low_b) + np.square(high_a - est_a)),
            -1.0,
            1.0,
        )
        delta_upper = np.clip(
            delta_est + np.sqrt(np.square(high_b - est_b) + np.square(est_a - low_a)),
            -1.0,
            1.0,
        )

        p_vals = np.ones(len(k_a), dtype=float)
        for idx in range(len(k_a)):
            na_i = int(round(max(0.0, float(n_a[idx]))))
            nb_i = int(round(max(0.0, float(n_b[idx]))))
            if na_i <= 0 or nb_i <= 0:
                p_vals[idx] = 1.0
                continue
            ka_i = int(round(np.clip(float(k_a[idx]), 0.0, float(na_i))))
            kb_i = int(round(np.clip(float(k_b[idx]), 0.0, float(nb_i))))
            table = np.array([[kb_i, nb_i - kb_i], [ka_i, na_i - ka_i]], dtype=int)
            p_vals[idx] = float(stats.fisher_exact(table, alternative="two-sided").pvalue)

        q_vals = (
            np.asarray(stats.false_discovery_control(np.clip(p_vals, 0.0, 1.0), method="bh"), dtype=float)
            if len(p_vals) > 0
            else np.array([], dtype=float)
        )
        return delta_lower, delta_upper, p_vals, q_vals

    def contrast(self, label_a: str, label_b: str) -> pl.DataFrame:
        """Computes per-target differential prevalence, fold change, rank shifts, and FDR between two formulations.

        Args:
            label_a: Key of the baseline formulation in ``self.formulations_``.
            label_b: Key of the comparison formulation in ``self.formulations_``.

        Returns:
            A Polars ``DataFrame`` containing ``delta_prevalence``, ``delta_lower``, ``delta_upper``,
            ``log2_fold_change``, ``delta_rank``, ``p_or_lfsr``, ``q_value``, and ``significant``.

        Raises:
            RuntimeError: If the comparison has not been fitted yet.
            KeyError: If either ``label_a`` or ``label_b`` is not present in ``self.formulations_``.
        """
        self.check_is_fitted()
        if label_a not in self.formulations_ or label_b not in self.formulations_:
            raise KeyError(
                f"Both {label_a!r} and {label_b!r} must be in fitted formulations: {list(self.formulations_.keys())}."
            )
        return self._contrast_pair(label_a, self.formulations_[label_a], label_b, self.formulations_[label_b])

    def _contrast_pair(
        self,
        label_a: str,
        form_a: Formulation,
        label_b: str,
        form_b: Formulation,
    ) -> pl.DataFrame:
        target_col = form_a.estimator_.target_col
        est_col_a = form_a.estimator_.estimate.column
        est_col_b = form_b.estimator_.estimate.column

        df_a = form_a.rankings_.select(
            [
                pl.col(target_col).cast(pl.String).alias(target_col),
                pl.col("event").alias("event_a"),
                pl.col("n").alias("n_a"),
                pl.col(est_col_a).alias("estimate_a"),
                pl.col("lower").alias("lower_a"),
                pl.col("upper").alias("upper_a"),
                pl.col("rank").alias("rank_a"),
            ]
        )
        df_b = form_b.rankings_.select(
            [
                pl.col(target_col).cast(pl.String).alias(target_col),
                pl.col("event").alias("event_b"),
                pl.col("n").alias("n_b"),
                pl.col(est_col_b).alias("estimate_b"),
                pl.col("lower").alias("lower_b"),
                pl.col("upper").alias("upper_b"),
                pl.col("rank").alias("rank_b"),
            ]
        )

        max_n_a = int(np.max(df_a["n_a"].to_numpy())) if len(df_a) > 0 else 0
        max_n_b = int(np.max(df_b["n_b"].to_numpy())) if len(df_b) > 0 else 0
        fallback_rank_a = len(df_a) + 1
        fallback_rank_b = len(df_b) + 1

        merged = (
            df_a.join(df_b, on=target_col, how="full", coalesce=True)
            .with_columns(
                pl.col("event_a").fill_null(0),
                pl.col("n_a").fill_null(max_n_a),
                pl.col("estimate_a").fill_null(0.0),
                pl.col("lower_a").fill_null(0.0),
                pl.col("upper_a").fill_null(0.0),
                pl.col("rank_a").fill_null(fallback_rank_a),
                pl.col("event_b").fill_null(0),
                pl.col("n_b").fill_null(max_n_b),
                pl.col("estimate_b").fill_null(0.0),
                pl.col("lower_b").fill_null(0.0),
                pl.col("upper_b").fill_null(0.0),
                pl.col("rank_b").fill_null(fallback_rank_b),
            )
            .sort("rank_a")
        )

        target_list = merged[target_col].to_list()
        est_a = merged["estimate_a"].to_numpy().astype(float)
        est_b = merged["estimate_b"].to_numpy().astype(float)
        delta_prev = est_b - est_a
        eps = 1e-6
        log2_fc = np.log2((est_b + eps) / (est_a + eps))

        draws_res_a = form_a.estimator_.target_posterior_draws(weighting=form_a.weighting)
        draws_res_b = form_b.estimator_.target_posterior_draws(weighting=form_b.weighting)

        if draws_res_a is not None and draws_res_b is not None and len(target_list) > 0:
            t_a, mat_a = draws_res_a
            t_b, mat_b = draws_res_b
            map_a = {t: i for i, t in enumerate(t_a)}
            map_b = {t: i for i, t in enumerate(t_b)}
            aligned_a = np.zeros((mat_a.shape[0], len(target_list)), dtype=float)
            aligned_b = np.zeros((mat_b.shape[0], len(target_list)), dtype=float)
            for col_i, t_name in enumerate(target_list):
                if t_name in map_a:
                    aligned_a[:, col_i] = mat_a[:, map_a[t_name]]
                if t_name in map_b:
                    aligned_b[:, col_i] = mat_b[:, map_b[t_name]]

            delta_lower, delta_upper, p_or_lfsr, q_vals = self._compute_bayesian_differential(
                aligned_a,
                aligned_b,
                alpha=self.alpha,
                null_margin=self.null_margin,
            )
            method_label = "bayesian_lfsr"
        else:
            delta_lower, delta_upper, p_or_lfsr, q_vals = self._compute_frequentist_differential(
                merged["event_a"].to_numpy().astype(float),
                merged["n_a"].to_numpy().astype(float),
                est_a,
                merged["lower_a"].to_numpy().astype(float),
                merged["upper_a"].to_numpy().astype(float),
                merged["event_b"].to_numpy().astype(float),
                merged["n_b"].to_numpy().astype(float),
                est_b,
                merged["lower_b"].to_numpy().astype(float),
                merged["upper_b"].to_numpy().astype(float),
            )
            method_label = "fisher_exact_bh"

        return merged.select(
            [
                pl.lit(label_a).alias("model_a"),
                pl.lit(label_b).alias("model_b"),
                pl.col(target_col),
                pl.col("event_a"),
                pl.col("n_a"),
                pl.col("estimate_a"),
                pl.col("event_b"),
                pl.col("n_b"),
                pl.col("estimate_b"),
                pl.Series("delta_prevalence", delta_prev, dtype=pl.Float64),
                pl.Series("delta_lower", delta_lower, dtype=pl.Float64),
                pl.Series("delta_upper", delta_upper, dtype=pl.Float64),
                pl.Series("log2_fold_change", log2_fc, dtype=pl.Float64),
                (pl.col("rank_b") - pl.col("rank_a")).alias("delta_rank"),
                pl.lit(method_label).alias("test_method"),
                pl.Series("p_or_lfsr", p_or_lfsr, dtype=pl.Float64),
                pl.Series("q_value", q_vals, dtype=pl.Float64),
                pl.Series("significant", q_vals < self.alpha, dtype=pl.Boolean),
            ]
        )

    def _compute_all_differential_prevalence(self) -> None:
        names = list(self.formulations_.keys())
        diff_frames: list[pl.DataFrame] = []
        for name_a, name_b in itertools.combinations(names, 2):
            diff_frames.append(
                self._contrast_pair(name_a, self.formulations_[name_a], name_b, self.formulations_[name_b])
            )
        self.differential_prevalence_ = (
            pl.concat(diff_frames, how="diagonal_relaxed") if diff_frames else pl.DataFrame()
        )

    def _compute_rank_shifts_and_concordance(self) -> None:
        names = list(self.formulations_.keys())
        first_form = self.formulations_[names[0]]
        target_col = first_form.estimator_.target_col
        est_col = first_form.estimator_.estimate.column

        # 1. Build wide rank_shifts_ table
        base_df = first_form.rankings_.select(
            [
                pl.col(target_col).cast(pl.String).alias(target_col),
                pl.col("rank").alias(f"rank_{names[0]}"),
                pl.col(est_col).alias(f"estimate_{names[0]}"),
            ]
        )
        for name in names[1:]:
            f_df = self.formulations_[name].rankings_.select(
                [
                    pl.col(target_col).cast(pl.String).alias(target_col),
                    pl.col("rank").alias(f"rank_{name}"),
                    pl.col(est_col).alias(f"estimate_{name}"),
                ]
            )
            base_df = base_df.join(f_df, on=target_col, how="full", coalesce=True)

        for name in names:
            fallback_rank = len(self.formulations_[name].rankings_) + 1
            base_df = base_df.with_columns(
                pl.col(f"rank_{name}").fill_null(fallback_rank),
                pl.col(f"estimate_{name}").fill_null(0.0),
            )

        rank_cols = [pl.col(f"rank_{name}") for name in names]
        base_df = base_df.with_columns(
            min_rank=pl.min_horizontal(rank_cols),
            max_rank=pl.max_horizontal(rank_cols),
        ).with_columns(
            rank_range=pl.col("max_rank") - pl.col("min_rank"),
        )
        if len(names) == 2:
            base_df = base_df.with_columns(
                delta_rank=pl.col(f"rank_{names[1]}") - pl.col(f"rank_{names[0]}"),
            )
        self.rank_shifts_ = base_df.sort(f"rank_{names[0]}")

        # 2. Build pairwise concordance_ table
        concordance_rows: list[dict[str, Any]] = []
        for name_a, name_b in itertools.combinations(names, 2):
            form_a = self.formulations_[name_a]
            form_b = self.formulations_[name_b]

            r_a = self.rank_shifts_[f"rank_{name_a}"].to_numpy().astype(float)
            r_b = self.rank_shifts_[f"rank_{name_b}"].to_numpy().astype(float)

            if len(r_a) >= 2 and np.std(r_a) > 0.0 and np.std(r_b) > 0.0:
                sp_res: Any = stats.spearmanr(r_a, r_b)
                kt_res: Any = stats.kendalltau(r_a, r_b)
                spearman_rho = float(sp_res.statistic)
                kendall_tau = float(kt_res.statistic)
            else:
                spearman_rho = 1.0
                kendall_tau = 1.0

            set_a = set(form_a.selected_targets_)
            set_b = set(form_b.selected_targets_)
            shared = [t for t in form_a.selected_targets_ if t in set_b]
            unique_a = [t for t in form_a.selected_targets_ if t not in set_b]
            unique_b = [t for t in form_b.selected_targets_ if t not in set_a]
            union_len = len(set_a | set_b)
            jaccard = len(shared) / union_len if union_len > 0 else 1.0

            concordance_rows.append(
                {
                    "model_a": name_a,
                    "model_b": name_b,
                    "spearman_rho": spearman_rho,
                    "kendall_tau": kendall_tau,
                    "top_v_overlap": len(shared),
                    "jaccard_similarity": jaccard,
                    "shared_targets": ", ".join(shared),
                    "unique_to_a": ", ".join(unique_a),
                    "unique_to_b": ", ".join(unique_b),
                }
            )

        self.concordance_ = pl.DataFrame(concordance_rows)

check_is_fitted

check_is_fitted() -> None

Validates that the formulation comparison has been fitted.

Raises:

Type Description
RuntimeError

If fit() has not been called on this instance.

Source code in src/seroepi/estimators/formulation.py
def check_is_fitted(self) -> None:
    """Validates that the formulation comparison has been fitted.

    Raises:
        RuntimeError: If ``fit()`` has not been called on this instance.
    """
    if not getattr(self, "is_fitted_", False):
        raise RuntimeError(
            f"This {self.__class__.__name__} instance is not fitted yet. "
            "Call 'fit' with an Aggregator before using it."
        )

from_strata classmethod

from_strata(
    estimator: BaseEstimator,
    X: Aggregator,
    strata_values: Sequence[str] | None = None,
    *,
    stratum_col: str | None = None,
    valency: int = 6,
    weighting: Literal["pooled", "equal"] = "pooled",
    alpha: float = 0.05,
    null_margin: float = 0.0,
) -> Self

Slices a stratified Aggregator across strata into cohort Formulation objects and fits a comparison.

Parameters:

Name Type Description Default
estimator BaseEstimator

Unfitted or fitted BaseEstimator template cloned for each stratum cohort.

required
X Aggregator

Stratified fitted Aggregator instance.

required
strata_values Sequence[str] | None

Optional subset of stratum values to compare (requires at least 2).

None
stratum_col str | None

Optional stratum column name when X has multiple strata.

None
valency int

Target formulation valency for each cohort.

6
weighting Literal['pooled', 'equal']

Stratum weighting scheme ("pooled" or "equal").

'pooled'
alpha float

Significance level for differential prevalence intervals and FDR control.

0.05
null_margin float

Minimum absolute prevalence difference margin >= 0.0 for Bayesian LFSR testing.

0.0

Returns:

Type Description
Self

A fitted FormulationComparison instance contrasting the selected strata cohorts.

Raises:

Type Description
ValueError

If X is unstratified, stratum_col is missing, or fewer than 2 strata values are selected.

Source code in src/seroepi/estimators/formulation.py
@classmethod
def from_strata(
    cls,
    estimator: BaseEstimator,
    X: Aggregator,
    strata_values: Sequence[str] | None = None,
    *,
    stratum_col: str | None = None,
    valency: int = 6,
    weighting: Literal["pooled", "equal"] = "pooled",
    alpha: float = 0.05,
    null_margin: float = 0.0,
) -> Self:
    """Slices a stratified ``Aggregator`` across strata into cohort ``Formulation`` objects and fits a comparison.

    Args:
        estimator: Unfitted or fitted ``BaseEstimator`` template cloned for each stratum cohort.
        X: Stratified fitted ``Aggregator`` instance.
        strata_values: Optional subset of stratum values to compare (requires at least 2).
        stratum_col: Optional stratum column name when ``X`` has multiple strata.
        valency: Target formulation valency for each cohort.
        weighting: Stratum weighting scheme (``"pooled"`` or ``"equal"``).
        alpha: Significance level for differential prevalence intervals and FDR control.
        null_margin: Minimum absolute prevalence difference margin ``>= 0.0`` for Bayesian LFSR testing.

    Returns:
        A fitted ``FormulationComparison`` instance contrasting the selected strata cohorts.

    Raises:
        ValueError: If ``X`` is unstratified, ``stratum_col`` is missing, or fewer than 2 strata values are selected.
    """
    Formulation._validate_aggregator(X)
    if X.n_strata == 0:
        raise ValueError("FormulationComparison.from_strata requires a stratified Aggregator (got 0 strata).")
    col = stratum_col or (X.single_stratum_col if X.is_single_stratum else X.strata_cols[0])
    if col not in X.data_.columns:
        raise ValueError(f"Stratum column {col!r} not found in Aggregator columns: {X.data_.columns}.")

    available_vals = [str(v) for v in X.data_[col].cast(pl.String).unique(maintain_order=True).to_list()]
    selected_vals = [str(v) for v in strata_values] if strata_values is not None else available_vals
    if len(selected_vals) < 2:
        raise ValueError(
            f"FormulationComparison.from_strata requires at least 2 strata values, got {selected_vals!r}."
        )

    prog = current_progress()
    n_cohorts = max(1, len(selected_vals))
    cohort_forms: dict[str, Formulation] = {}
    for idx, s_val in enumerate(selected_vals):
        cohort_agg = X.filter(**{col: s_val})
        c_start = 0.85 * (idx / n_cohorts)
        c_end = 0.85 * ((idx + 1) / n_cohorts)
        with prog.subrange(
            c_start,
            c_end,
            message="Comparing Cohort Formulations",
            detail_prefix=f"Cohort {idx + 1}/{n_cohorts} ({s_val})",
        ):
            cohort_forms[s_val] = Formulation(
                estimator=clone(estimator),
                valency=valency,
                weighting=weighting,
                cv=None,
                alpha=alpha,
                name=s_val,
            ).fit(cohort_agg)

    comp = cls(
        formulations=cohort_forms,
        valency=valency,
        weighting=weighting,
        cv=None,
        alpha=alpha,
        null_margin=null_margin,
    )
    with prog.subrange(0.85, 1.0, message="Computing Differential Prevalence & FDR"):
        return comp.fit(X=None)

fit

fit(X: Aggregator | None = None, y: Any = None) -> Self

Fits constituent formulations (or preserves pre-fitted cohort formulations) and computes comparisons.

Parameters:

Name Type Description Default
X Aggregator | None

Optional fitted Aggregator instance used to fit any unfitted constituent formulations.

None
y Any

Ignored; present for scikit-learn compatibility.

None

Returns:

Type Description
Self

The fitted FormulationComparison instance (self).

Raises:

Type Description
ValueError

If any constituent formulation/estimator is unfitted and X is None.

TypeError

If any constituent entry is not a Formulation or BaseEstimator.

Source code in src/seroepi/estimators/formulation.py
def fit(self, X: Aggregator | None = None, y: Any = None) -> Self:
    """Fits constituent formulations (or preserves pre-fitted cohort formulations) and computes comparisons.

    Args:
        X: Optional fitted ``Aggregator`` instance used to fit any unfitted constituent formulations.
        y: Ignored; present for scikit-learn compatibility.

    Returns:
        The fitted ``FormulationComparison`` instance (``self``).

    Raises:
        ValueError: If any constituent formulation/estimator is unfitted and ``X`` is ``None``.
        TypeError: If any constituent entry is not a ``Formulation`` or ``BaseEstimator``.
    """
    prog = current_progress()
    if X is not None:
        Formulation._validate_aggregator(X)

    fitted_forms: dict[str, Formulation] = {}
    rank_frames: list[pl.DataFrame] = []
    items = list(self.formulations.items())
    n_items = max(1, len(items))

    for idx, (label, spec) in enumerate(items):
        s_start = 0.80 * (idx / n_items)
        s_end = 0.80 * ((idx + 1) / n_items)
        with prog.subrange(
            s_start,
            s_end,
            message="Fitting Formulation Comparison",
            detail_prefix=f"Model {idx + 1}/{n_items} ({label})",
        ) as sub_prog:
            if isinstance(spec, Formulation):
                if getattr(spec, "is_fitted_", False) and getattr(spec, "aggregator_", None) is not None:
                    form = spec
                elif X is not None:
                    form = clone(spec).fit(X, y=y)
                else:
                    raise ValueError(
                        f"Formulation {label!r} is not fitted and X=None was passed to FormulationComparison.fit()."
                    )
            elif isinstance(spec, BaseEstimator):
                if getattr(spec, "is_fitted_", False) and getattr(spec, "aggregator_", None) is not None:
                    form = Formulation._from_fitted_estimator(
                        spec,
                        valency=self.valency,
                        weighting=self.weighting,
                        cv=self.cv,
                        alpha=self.alpha,
                        name=label,
                    )
                elif X is not None:
                    form = Formulation(
                        estimator=spec,
                        valency=self.valency,
                        weighting=self.weighting,
                        cv=self.cv,
                        alpha=self.alpha,
                        name=label,
                    ).fit(X, y=y)
                else:
                    raise ValueError(
                        f"Estimator {label!r} is not fitted and X=None was passed to FormulationComparison.fit()."
                    )
            else:
                raise TypeError(
                    f"Formulation entry {label!r} must be a Formulation or BaseEstimator, got {type(spec).__name__}."
                )
            sub_prog.report(1.0)

        fitted_forms[label] = form
        rank_frames.append(form.rankings_.with_columns(formulation=pl.lit(label)))

    self.aggregator_ = X if X is not None else next(iter(fitted_forms.values())).aggregator_
    self.formulations_ = fitted_forms
    self.rankings_ = pl.concat(rank_frames, how="diagonal_relaxed")
    prog.report(
        0.88, message="Computing Formulation Comparison", detail="Evaluating rank shifts and concordance..."
    )
    self._compute_rank_shifts_and_concordance()
    prog.report(0.94, message="Computing Formulation Comparison", detail="Testing differential prevalence & FDR...")
    self._compute_all_differential_prevalence()
    self.is_fitted_ = True
    prog.report(1.0, message="Formulation Comparison Complete", detail="Ready")
    return self

contrast

contrast(label_a: str, label_b: str) -> DataFrame

Computes per-target differential prevalence, fold change, rank shifts, and FDR between two formulations.

Parameters:

Name Type Description Default
label_a str

Key of the baseline formulation in self.formulations_.

required
label_b str

Key of the comparison formulation in self.formulations_.

required

Returns:

Type Description
DataFrame

A Polars DataFrame containing delta_prevalence, delta_lower, delta_upper,

DataFrame

log2_fold_change, delta_rank, p_or_lfsr, q_value, and significant.

Raises:

Type Description
RuntimeError

If the comparison has not been fitted yet.

KeyError

If either label_a or label_b is not present in self.formulations_.

Source code in src/seroepi/estimators/formulation.py
def contrast(self, label_a: str, label_b: str) -> pl.DataFrame:
    """Computes per-target differential prevalence, fold change, rank shifts, and FDR between two formulations.

    Args:
        label_a: Key of the baseline formulation in ``self.formulations_``.
        label_b: Key of the comparison formulation in ``self.formulations_``.

    Returns:
        A Polars ``DataFrame`` containing ``delta_prevalence``, ``delta_lower``, ``delta_upper``,
        ``log2_fold_change``, ``delta_rank``, ``p_or_lfsr``, ``q_value``, and ``significant``.

    Raises:
        RuntimeError: If the comparison has not been fitted yet.
        KeyError: If either ``label_a`` or ``label_b`` is not present in ``self.formulations_``.
    """
    self.check_is_fitted()
    if label_a not in self.formulations_ or label_b not in self.formulations_:
        raise KeyError(
            f"Both {label_a!r} and {label_b!r} must be in fitted formulations: {list(self.formulations_.keys())}."
        )
    return self._contrast_pair(label_a, self.formulations_[label_a], label_b, self.formulations_[label_b])

Spatiotemporal & Genomic Transmission Clustering (seroepi.estimators.transmission)

transmission

Scikit-learn compatible estimator for estimating pathogen transmission between isolates.

Classes:

Name Description
TransmissionEstimator

Scikit-learn compatible neighbor & cluster estimator for pathogen transmission on a Dataset.

Functions:

Name Description
parse_temporal_cutoff_days

Converts a temporal cutoff specification into a non-negative float number of days.

haversine_distance_km

Computes vectorized great-circle Haversine distance in kilometers between coordinate arrays.

TransmissionEstimator

Bases: StratifiedResultMixin, NearestNeighbors

Scikit-learn compatible neighbor & cluster estimator for pathogen transmission on a Dataset.

Combines pairwise genomic distances (Dataset.distances) with clonal/lineage stratification (strata) and epidemiological Spatial and Temporal variables to infer transmission links, connected transmission clusters (labels_), isolate-level summaries (isolates_), pairwise transmission edge lists (pairs_), and stratum-level transmission tables (data_).

Parameters:

Name Type Description Default
n_neighbors int

Number of nearest transmission neighbors to retrieve in kneighbors().

5
radius float

Maximum genomic distance (e.g. SNPs or cgMLST alleles) for a transmission link.

20.0
strata Sequence[Variable] | Variable | None

Clonal, spatial, temporal, or metadata Variable(s) used to block candidate links and stratify the output data_ summary table.

None
spatial Sequence[Spatial] | Spatial | None

Explicit Spatial variable (categorical locality at a Spatial.Resolution) or a pair of (lat, lon) Spatial(resolution=Spatial.Resolution.POINT) variables. If omitted, any Spatial variables inside strata are used automatically.

None
temporal Temporal | None

Explicit Temporal variable for date/time transmission constraints. If omitted, any Temporal variable inside strata is used automatically.

None
spatial_resolution Resolution | str | None

Optional Spatial.Resolution override (e.g. SITE, CITY, COUNTRY, REGION, CONTINENT, GLOBAL) applied to categorical Spatial variables.

None
temporal_resolution Resolution | str | None

Optional Temporal.Resolution override (e.g. DAY, EPI_WEEK, WEEK, MONTH, QUARTER, YEAR) applied when temporal_cutoff is not specified.

None
spatial_cutoff float | None

Optional maximum geographic Haversine distance in kilometers (km) between isolates when POINT coordinates (or country centroids) are used.

None
temporal_cutoff str | float | timedelta | None

Optional maximum time elapsed between linked isolates (e.g. '14d', '4w', '3mo', '1y', timedelta(days=30), or numeric days).

None
directed bool

If True, requires source_time <= target_time for temporal links and counts putative secondary recipients (in_degree >= 1) as event in data_.

False
target Target | Criterion | Variable | None

Optional Target, Criterion, or Variable. Defaults to a binary 'transmission' target so event / n estimates the stratum transmission proportion.

None
filter_qc bool

If True (default), filters the input Dataset by its registered QC variables.

True
pad_zeros bool

If True, zero-pads unobserved strata combinations in data_.

False
include_singletons bool

If False (default), unlinked singleton isolates receive -1 in labels_ (None in isolates_['cluster']). If True, singletons receive unique cluster IDs after multi-isolate clusters.

False
cluster_prefix str

String prefix for formatted cluster identifiers (default 'TC_').

'TC_'
algorithm Literal['auto', 'ball_tree', 'kd_tree', 'brute']

NearestNeighbors algorithm when fitted on raw feature matrices.

'auto'
leaf_size int

Leaf size passed to BallTree or KDTree for raw matrix inputs.

30
metric str | Callable[..., Any]

Distance metric for raw matrix inputs (defaults to 'precomputed').

'precomputed'
p float

Power parameter for Minkowski metric on raw matrix inputs.

2
metric_params dict[str, Any] | None

Additional keyword arguments for metric function on raw matrix inputs.

None
n_jobs int | None

Number of parallel jobs for neighbor search on raw matrix inputs.

None

Attributes:

Name Type Description
is_fitted_ bool

Boolean flag indicating whether fit() has been called.

data_ DataFrame

Stratified summary Polars DataFrame compatible with Aggregator and BaseEstimator.

pairs_ DataFrame

Polars DataFrame of inferred isolate-to-isolate transmission links within radius.

isolates_ DataFrame

Polars DataFrame of per-isolate cluster assignments, degrees, and nearest neighbors.

labels_ ndarray

1D int64 NumPy array of cluster IDs for each isolate (-1 for singletons unless include_singletons=True).

sample_ids_ ndarray

1D string NumPy array of fitted isolate identifiers.

Examples:

>>> import polars as pl
>>> from scipy.sparse import csr_matrix
>>> from seroepi.dataset import Dataset
>>> from seroepi.dist import GenomicDistances
>>> from seroepi.estimators.transmission import TransmissionEstimator
>>> from seroepi.variables import Identifier, Spatial
>>> dist = GenomicDistances(csr_matrix([[0.0, 2.0, 50.0], [2.0, 0.0, 50.0], [50.0, 50.0, 0.0]]), ("s1", "s2", "s3"))
>>> ds = Dataset(
...     pl.DataFrame({"id": ["s1", "s2", "s3"], "site": ["W1", "W1", "W1"]}),
...     variables={"id": Identifier("id"), "site": Spatial("site")},
...     distances=dist,
... )
>>> te = TransmissionEstimator(radius=10.0, strata=[ds.var("site")]).fit(ds)
>>> te.labels_.tolist()
[0, 0, -1]

Methods:

Name Description
fit

Fits the transmission model on a Dataset (or precomputed/feature matrix X).

radius_neighbors

Finds all transmission neighbors within a given genomic radius for each isolate.

radius_neighbors_graph

Computes the (weighted) sparse graph of isolate transmission neighbors within radius.

kneighbors

Finds the K-nearest transmission neighbors for each isolate.

kneighbors_graph

Computes the (weighted) sparse graph of K-nearest transmission neighbors.

fit_predict

Fits the transmission model on X and returns integer cluster labels_.

to_dataset

Returns a new Dataset with the fitted transmission cluster labels joined as a TRANSMISSION_CLONE column.

Source code in src/seroepi/estimators/transmission.py
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
class TransmissionEstimator(StratifiedResultMixin, NearestNeighbors):
    """Scikit-learn compatible neighbor & cluster estimator for pathogen transmission on a `Dataset`.

    Combines pairwise genomic distances (`Dataset.distances`) with clonal/lineage stratification
    (`strata`) and epidemiological `Spatial` and `Temporal` variables to infer transmission links,
    connected transmission clusters (`labels_`), isolate-level summaries (`isolates_`), pairwise
    transmission edge lists (`pairs_`), and stratum-level transmission tables (`data_`).

    Args:
        n_neighbors: Number of nearest transmission neighbors to retrieve in `kneighbors()`.
        radius: Maximum genomic distance (e.g. SNPs or cgMLST alleles) for a transmission link.
        strata: Clonal, spatial, temporal, or metadata `Variable`(s) used to block candidate
            links and stratify the output `data_` summary table.
        spatial: Explicit `Spatial` variable (categorical locality at a `Spatial.Resolution`) or
            a pair of `(lat, lon)` `Spatial(resolution=Spatial.Resolution.POINT)` variables.
            If omitted, any `Spatial` variables inside `strata` are used automatically.
        temporal: Explicit `Temporal` variable for date/time transmission constraints.
            If omitted, any `Temporal` variable inside `strata` is used automatically.
        spatial_resolution: Optional `Spatial.Resolution` override (e.g. `SITE`, `CITY`, `COUNTRY`,
            `REGION`, `CONTINENT`, `GLOBAL`) applied to categorical `Spatial` variables.
        temporal_resolution: Optional `Temporal.Resolution` override (e.g. `DAY`, `EPI_WEEK`,
            `WEEK`, `MONTH`, `QUARTER`, `YEAR`) applied when `temporal_cutoff` is not specified.
        spatial_cutoff: Optional maximum geographic Haversine distance in kilometers (km) between
            isolates when `POINT` coordinates (or country centroids) are used.
        temporal_cutoff: Optional maximum time elapsed between linked isolates (e.g. `'14d'`,
            `'4w'`, `'3mo'`, `'1y'`, `timedelta(days=30)`, or numeric days).
        directed: If True, requires `source_time <= target_time` for temporal links and counts
            putative secondary recipients (`in_degree >= 1`) as `event` in `data_`.
        target: Optional `Target`, `Criterion`, or `Variable`. Defaults to a binary
            `'transmission'` target so `event / n` estimates the stratum transmission proportion.
        filter_qc: If True (default), filters the input `Dataset` by its registered `QC` variables.
        pad_zeros: If True, zero-pads unobserved strata combinations in `data_`.
        include_singletons: If False (default), unlinked singleton isolates receive `-1` in
            `labels_` (`None` in `isolates_['cluster']`). If True, singletons receive unique
            cluster IDs after multi-isolate clusters.
        cluster_prefix: String prefix for formatted cluster identifiers (default `'TC_'`).
        algorithm: NearestNeighbors algorithm when fitted on raw feature matrices.
        leaf_size: Leaf size passed to BallTree or KDTree for raw matrix inputs.
        metric: Distance metric for raw matrix inputs (defaults to `'precomputed'`).
        p: Power parameter for Minkowski metric on raw matrix inputs.
        metric_params: Additional keyword arguments for metric function on raw matrix inputs.
        n_jobs: Number of parallel jobs for neighbor search on raw matrix inputs.

    Attributes:
        is_fitted_: Boolean flag indicating whether ``fit()`` has been called.
        data_: Stratified summary Polars ``DataFrame`` compatible with ``Aggregator`` and ``BaseEstimator``.
        pairs_: Polars ``DataFrame`` of inferred isolate-to-isolate transmission links within ``radius``.
        isolates_: Polars ``DataFrame`` of per-isolate cluster assignments, degrees, and nearest neighbors.
        labels_: 1D int64 NumPy array of cluster IDs for each isolate (``-1`` for singletons unless ``include_singletons=True``).
        sample_ids_: 1D string NumPy array of fitted isolate identifiers.

    Examples:
        >>> import polars as pl
        >>> from scipy.sparse import csr_matrix
        >>> from seroepi.dataset import Dataset
        >>> from seroepi.dist import GenomicDistances
        >>> from seroepi.estimators.transmission import TransmissionEstimator
        >>> from seroepi.variables import Identifier, Spatial
        >>> dist = GenomicDistances(csr_matrix([[0.0, 2.0, 50.0], [2.0, 0.0, 50.0], [50.0, 50.0, 0.0]]), ("s1", "s2", "s3"))
        >>> ds = Dataset(
        ...     pl.DataFrame({"id": ["s1", "s2", "s3"], "site": ["W1", "W1", "W1"]}),
        ...     variables={"id": Identifier("id"), "site": Spatial("site")},
        ...     distances=dist,
        ... )
        >>> te = TransmissionEstimator(radius=10.0, strata=[ds.var("site")]).fit(ds)
        >>> te.labels_.tolist()
        [0, 0, -1]
    """

    is_fitted_: bool = False
    data_: pl.DataFrame
    pairs_: pl.DataFrame
    isolates_: pl.DataFrame
    labels_: np.ndarray
    sample_ids_: np.ndarray
    variables_: dict[str, Variable]
    target_: Variable
    strata_: tuple[Variable, ...]
    adjust_for_: Variable | None = None
    is_compositional_: bool = False
    is_padded_: bool = False

    def __init__(
        self,
        n_neighbors: int = 5,
        *,
        radius: float = 20.0,
        strata: Sequence[Variable] | Variable | None = None,
        spatial: Sequence[Spatial] | Spatial | None = None,
        temporal: Temporal | None = None,
        spatial_resolution: Spatial.Resolution | str | None = None,
        temporal_resolution: Temporal.Resolution | str | None = None,
        spatial_cutoff: float | None = None,
        temporal_cutoff: str | float | timedelta | None = None,
        directed: bool = False,
        target: Target | Criterion | Variable | None = None,
        filter_qc: bool = True,
        pad_zeros: bool = False,
        include_singletons: bool = False,
        cluster_prefix: str = "TC_",
        algorithm: Literal["auto", "ball_tree", "kd_tree", "brute"] = "auto",
        leaf_size: int = 30,
        metric: str | Callable[..., Any] = "precomputed",
        p: float = 2,
        metric_params: dict[str, Any] | None = None,
        n_jobs: int | None = None,
    ) -> None:
        """Initializes the TransmissionEstimator.

        Args:
            n_neighbors: Number of nearest transmission neighbors to retrieve in ``kneighbors()``.
            radius: Maximum genomic distance for a transmission link.
            strata: Optional blocking/stratification ``Variable`` or sequence of ``Variable`` instances.
            spatial: Optional explicit ``Spatial`` variable or ``(lat, lon)`` pair.
            temporal: Optional explicit ``Temporal`` variable.
            spatial_resolution: Optional ``Spatial.Resolution`` override.
            temporal_resolution: Optional ``Temporal.Resolution`` override.
            spatial_cutoff: Optional maximum Haversine distance in kilometers (km).
            temporal_cutoff: Optional maximum elapsed duration between linked isolates.
            directed: Whether to enforce ``source_time <= target_time`` and count recipients as ``event``.
            target: Optional ``Target``, ``Criterion``, or ``Variable``.
            filter_qc: Whether to apply dataset QC filters before fitting.
            pad_zeros: Whether to zero-pad unobserved strata combinations in ``data_``.
            include_singletons: Whether unlinked singleton isolates receive unique cluster IDs instead of ``-1``.
            cluster_prefix: String prefix for formatted cluster names (e.g., ``"TC_"``).
            algorithm: ``NearestNeighbors`` algorithm for raw matrix inputs.
            leaf_size: Leaf size for ``BallTree`` or ``KDTree`` on raw matrix inputs.
            metric: Distance metric for raw matrix inputs.
            p: Power parameter for Minkowski metric on raw matrix inputs.
            metric_params: Additional metric keyword arguments for raw matrix inputs.
            n_jobs: Number of parallel jobs for raw matrix neighbor searches.

        Raises:
            TypeError: If ``strata``, ``spatial``, ``temporal``, or ``target`` have invalid types.
            ValueError: If ``spatial_cutoff`` or ``temporal_cutoff`` are negative or malformed.
        """
        super().__init__(
            n_neighbors=n_neighbors,
            radius=radius,
            algorithm=algorithm,
            leaf_size=leaf_size,
            metric=metric,
            p=p,
            metric_params=metric_params,
            n_jobs=n_jobs,
        )
        self.strata = self._normalize_strata(strata)
        self.spatial = self._normalize_spatial(spatial)
        if temporal is not None and not isinstance(temporal, Temporal):
            raise TypeError(
                f"temporal must be a Temporal Variable instance, got {type(temporal).__name__}. "
                "Use dataset.var(...) to retrieve it."
            )
        self.temporal = temporal
        self.spatial_resolution = spatial_resolution
        self.temporal_resolution = temporal_resolution
        if spatial_cutoff is not None and float(spatial_cutoff) < 0:
            raise ValueError(f"spatial_cutoff must be non-negative, got {spatial_cutoff!r}.")
        self.spatial_cutoff = float(spatial_cutoff) if spatial_cutoff is not None else None
        if temporal_cutoff is not None:
            _ = parse_temporal_cutoff_days(temporal_cutoff)
        self.temporal_cutoff = temporal_cutoff
        self.directed = directed
        if target is not None and not isinstance(target, (Target, Criterion, Variable)):
            raise TypeError(f"target must be a Variable, Criterion, or Target instance, got {type(target).__name__}.")
        self.target = target
        self.filter_qc = filter_qc
        self.pad_zeros = pad_zeros
        self.include_singletons = include_singletons
        self.cluster_prefix = cluster_prefix

    def _get_frame(self) -> pl.DataFrame:
        return self.data_

    def _set_frame(self, df: pl.DataFrame) -> None:
        self.data_ = df

    @staticmethod
    def _normalize_strata(strata: Sequence[Variable] | Variable | None) -> tuple[Variable, ...]:
        """Normalizes `strata` into a tuple of `Variable` instances."""
        if strata is None:
            return ()
        if isinstance(strata, Variable):
            return (strata,)
        if isinstance(strata, (list, tuple, Sequence)):
            for idx, s in enumerate(strata):
                if not isinstance(s, Variable):
                    raise TypeError(
                        f"Strata item at index {idx} must be a Variable instance, got {type(s).__name__}. "
                        "Use dataset.var(...) to retrieve it."
                    )
            return tuple(strata)
        raise TypeError(f"strata must be a Variable or Sequence of Variables, got {type(strata).__name__}.")

    @staticmethod
    def _normalize_spatial(spatial: Sequence[Spatial] | Spatial | None) -> tuple[Spatial, ...]:
        """Normalizes `spatial` into a tuple of `Spatial` instances."""
        if spatial is None:
            return ()
        if isinstance(spatial, Spatial):
            return (spatial,)
        if isinstance(spatial, Variable):
            raise TypeError(f"spatial must be a Spatial Variable instance, got {type(spatial).__name__}.")
        if isinstance(spatial, (list, tuple, Sequence)):
            for idx, s in enumerate(spatial):
                if not isinstance(s, Spatial):
                    raise TypeError(
                        f"spatial item at index {idx} must be a Spatial Variable instance, got {type(s).__name__}."
                    )
            return tuple(spatial)
        raise TypeError(
            f"spatial must be a Spatial Variable or Sequence of Spatial Variables, got {type(spatial).__name__}."
        )

    @staticmethod
    def _coerce_spatial_resolution(res: Spatial.Resolution | str | None) -> Spatial.Resolution | None:
        if res is None:
            return None
        if isinstance(res, Spatial.Resolution):
            return res
        return Spatial.Resolution(str(res).lower())

    @staticmethod
    def _coerce_temporal_resolution(res: Temporal.Resolution | str | None) -> Temporal.Resolution | None:
        if res is None:
            return None
        if isinstance(res, Temporal.Resolution):
            return res
        return Temporal.Resolution(str(res).lower())

    def fit(self, X: Dataset | Any, y: Any = None) -> Self:
        """Fits the transmission model on a ``Dataset`` (or precomputed/feature matrix ``X``).

        Args:
            X: A ``Dataset`` with attached ``GenomicDistances``, or a 2D precomputed/feature matrix.
            y: Ignored; present for scikit-learn compatibility.

        Returns:
            The fitted ``TransmissionEstimator`` instance (``self``).

        Raises:
            ValueError: If ``X`` is a ``Dataset`` without attached ``GenomicDistances``.
        """
        if not isinstance(X, Dataset):
            return self._fit_raw_matrix(X, y)

        if X.distances is None:
            raise ValueError(
                "Cannot fit TransmissionEstimator on a Dataset without attached GenomicDistances. "
                "Call dataset.with_distances(...) first."
            )

        self._dataset_mode = True
        ds = X.qc() if self.filter_qc else X
        df = ds.data
        id_col = ds.identifier.column
        self.id_col_ = id_col

        # 1. Resolve Spatial & Temporal variables and Resolution overrides
        sp_res_override = self._coerce_spatial_resolution(self.spatial_resolution)
        tm_res_override = self._coerce_temporal_resolution(self.temporal_resolution)

        strata_vars: list[Variable] = []
        for s in self.strata:
            if isinstance(s, Spatial) and sp_res_override is not None and s.resolution != Spatial.Resolution.POINT:
                strata_vars.append(s.copy(resolution=sp_res_override))
            elif isinstance(s, Temporal) and tm_res_override is not None:
                strata_vars.append(s.copy(resolution=tm_res_override))
            else:
                strata_vars.append(s)

        explicit_spatial = list(self.spatial)
        if not explicit_spatial:
            explicit_spatial = [s for s in strata_vars if isinstance(s, Spatial)]
        else:
            explicit_spatial = [
                s.copy(resolution=sp_res_override)
                if sp_res_override is not None and s.resolution != Spatial.Resolution.POINT
                else s
                for s in explicit_spatial
            ]

        temporal_var = self.temporal
        if temporal_var is None:
            temporal_var = next((s for s in strata_vars if isinstance(s, Temporal)), None)
        elif tm_res_override is not None:
            temporal_var = temporal_var.copy(resolution=tm_res_override)

        # 2. Resolve optional Target
        resolved_target: Target | None = None
        is_compositional = False
        if self.target is not None:
            resolved_target = Target.coerce(self.target)
            is_compositional = resolved_target.is_compositional
            if not is_compositional:
                # Binary target: restrict transmission evaluation to target-positive isolates
                df = df.filter(resolved_target.to_expr())
            target_var = Trait(column="target", title=resolved_target.title, compositional=is_compositional)
        else:
            target_var = Trait(column="target", title="Transmission", compositional=False)

        n_samples = df.height
        sample_ids = df[id_col].cast(pl.String).to_numpy()
        self.sample_ids_ = sample_ids
        self.n_samples_fit_ = n_samples
        self.n_features_in_ = n_samples

        # 3. Project strata, spatial, and temporal columns on df
        proj_exprs: list[pl.Expr] = []
        strata_cols = [s.column for s in strata_vars]
        for s in strata_vars:
            if isinstance(s, Spatial) and s.resolution == Spatial.Resolution.GLOBAL:
                proj_exprs.append(pl.lit("Global").alias(s.column))
            else:
                proj_exprs.append(s.to_expr())

        for sp_var in explicit_spatial:
            if sp_var.column not in strata_cols and sp_var.resolution != Spatial.Resolution.POINT:
                if sp_var.resolution == Spatial.Resolution.GLOBAL:
                    proj_exprs.append(pl.lit("Global").alias(sp_var.column))
                else:
                    proj_exprs.append(sp_var.to_expr())

        if temporal_var is not None and temporal_var.column not in strata_cols:
            proj_exprs.append(temporal_var.to_expr())

        if temporal_var is not None:
            # Parse raw timestamps BEFORE truncating temporal column in df
            parsed_ts_series = df.select(temporal_var.parse_expr().alias("_parsed_ts"))["_parsed_ts"]
            ts_epoch_days = (
                (parsed_ts_series.dt.epoch(time_unit="us").cast(pl.Float64) / (86400.0 * 1_000_000.0))
                .fill_null(float("nan"))
                .to_numpy()
            )
        else:
            ts_epoch_days = None

        if resolved_target is not None and is_compositional:
            proj_exprs.append(resolved_target.to_category_expr().alias("_target_cat"))

        if proj_exprs:
            df_proj = df.with_columns(*proj_exprs)
        else:
            df_proj = df

        if resolved_target is not None and is_compositional:
            df_proj = df_proj.filter(pl.col("_target_cat").is_not_null())
            sample_ids = df_proj[id_col].cast(pl.String).to_numpy()
            if ts_epoch_days is not None:
                valid_mask = df.select(resolved_target.to_category_expr().is_not_null().alias("_m"))["_m"].to_numpy()
                ts_epoch_days = ts_epoch_days[valid_mask]
            n_samples = df_proj.height
            self.sample_ids_ = sample_ids
            self.n_samples_fit_ = n_samples
            self.n_features_in_ = n_samples

        # 4. Extract candidate off-diagonal genomic pairs aligned to sample_ids
        r_idx, c_idx, g_dist = self._extract_aligned_genomic_edges(ds, sample_ids)

        # 5. Apply Clonal/Strata, Spatial, and Temporal constraints to candidate edges
        r_idx, c_idx, g_dist, t_dist, s_dist = self._filter_candidate_edges(
            df_raw=df,
            df_proj=df_proj,
            r_idx=r_idx,
            c_idx=c_idx,
            g_dist=g_dist,
            strata_vars=strata_vars,
            explicit_spatial=explicit_spatial,
            temporal_var=temporal_var,
            ts_epoch_days=ts_epoch_days,
            has_comp_target=bool(resolved_target is not None and is_compositional),
        )

        # 6. Store sorted per-isolate valid neighbor structures (across all valid genomic distances)
        self._build_neighbor_structures(
            n_samples=n_samples,
            r_idx=r_idx,
            c_idx=c_idx,
            g_dist=g_dist,
            t_dist=t_dist,
            s_dist=s_dist,
        )

        # 7. Threshold by self.radius to build clusters (labels_), isolates_, pairs_, and stratified data_
        self._build_transmission_outputs(
            df_proj=df_proj,
            id_col=id_col,
            strata_vars=strata_vars,
            target_var=target_var,
            resolved_target=resolved_target,
            is_compositional=is_compositional,
        )

        self.is_fitted_ = True
        return self

    def _fit_raw_matrix(self, X: Any, y: Any = None) -> Self:
        """Fallback when fitted on a raw distance or feature matrix instead of a `Dataset`."""
        self._dataset_mode = False
        super().fit(X, y)
        n_samples = int(getattr(self, "n_samples_fit_", X.shape[0]))
        self.sample_ids_ = np.asarray([str(i) for i in range(n_samples)], dtype=str)
        self.id_col_ = "sample_id"

        r_graph = super().radius_neighbors_graph(radius=self.radius, mode="distance")
        coo = r_graph.tocoo()
        off_diag = coo.row != coo.col
        r_idx = coo.row[off_diag]
        c_idx = coo.col[off_diag]
        g_dist = np.asarray(coo.data[off_diag], dtype=np.float64)
        t_dist = np.full(len(r_idx), np.nan, dtype=np.float64)
        s_dist = np.full(len(r_idx), np.nan, dtype=np.float64)

        self._build_neighbor_structures(
            n_samples=n_samples,
            r_idx=r_idx,
            c_idx=c_idx,
            g_dist=g_dist,
            t_dist=t_dist,
            s_dist=s_dist,
        )
        df_proj = pl.DataFrame({self.id_col_: self.sample_ids_})
        target_var = Trait(column="target", title="Transmission", compositional=False)
        self._build_transmission_outputs(
            df_proj=df_proj,
            id_col=self.id_col_,
            strata_vars=[],
            target_var=target_var,
            resolved_target=None,
            is_compositional=False,
        )
        self.is_fitted_ = True
        return self

    @staticmethod
    def _extract_aligned_genomic_edges(
        ds: Dataset,
        sample_ids: np.ndarray,
    ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
        """Extracts directed off-diagonal edges `(r_idx, c_idx, g_dist)` aligned to `sample_ids`."""
        assert ds.distances is not None
        n = len(sample_ids)
        if n == 0 or ds.distances.n_samples == 0:
            return (
                np.array([], dtype=np.intp),
                np.array([], dtype=np.intp),
                np.array([], dtype=np.float64),
            )

        id_to_pos: dict[str, int] = {str(s): i for i, s in enumerate(sample_ids)}
        dist_samples = ds.distances.samples
        global_map = np.array([id_to_pos.get(str(s), -1) for s in dist_samples], dtype=np.intp)

        coo = ds.distances.array.tocoo()
        r_mapped = global_map[coo.row]
        c_mapped = global_map[coo.col]
        valid = (r_mapped >= 0) & (c_mapped >= 0) & (r_mapped != c_mapped)
        if not np.any(valid):
            return (
                np.array([], dtype=np.intp),
                np.array([], dtype=np.intp),
                np.array([], dtype=np.float64),
            )

        r_sub = r_mapped[valid]
        c_sub = c_mapped[valid]
        v_sub = np.asarray(coo.data[valid], dtype=np.float64)

        # Symmetrize so both (i, j) and (j, i) are present prior to directional filtering
        all_r = np.concatenate([r_sub, c_sub])
        all_c = np.concatenate([c_sub, r_sub])
        all_v = np.concatenate([v_sub, v_sub])

        linear = all_r.astype(np.int64) * n + all_c.astype(np.int64)
        _, uniq_idx = np.unique(linear, return_index=True)
        uniq_idx.sort()
        return all_r[uniq_idx], all_c[uniq_idx], all_v[uniq_idx]

    def _resolve_coordinates_arrays(
        self,
        df_raw: pl.DataFrame,
        explicit_spatial: list[Spatial],
    ) -> tuple[np.ndarray, np.ndarray] | None:
        """Extracts `(lat, lon)` float64 arrays when point coordinates or country centroids are used."""
        point_vars = [s for s in explicit_spatial if s.resolution == Spatial.Resolution.POINT]
        if len(point_vars) >= 2:
            lat_col, lon_col = point_vars[0].column, point_vars[1].column
            if any("lon" in point_vars[0].column.lower() and "lat" in point_vars[1].column.lower() for _ in [0]):
                lat_col, lon_col = point_vars[1].column, point_vars[0].column
            lat_arr = df_raw[lat_col].cast(pl.Float64, strict=False).fill_null(float("nan")).to_numpy()
            lon_arr = df_raw[lon_col].cast(pl.Float64, strict=False).fill_null(float("nan")).to_numpy()
            return lat_arr, lon_arr

        if self.spatial_cutoff is not None:
            # Check for centroid_lat / centroid_lon or lat / lon columns in df_raw
            for lat_c, lon_c in (("latitude", "longitude"), ("lat", "lon"), ("centroid_lat", "centroid_lon")):
                if lat_c in df_raw.columns and lon_c in df_raw.columns:
                    lat_arr = df_raw[lat_c].cast(pl.Float64, strict=False).fill_null(float("nan")).to_numpy()
                    lon_arr = df_raw[lon_c].cast(pl.Float64, strict=False).fill_null(float("nan")).to_numpy()
                    return lat_arr, lon_arr

            # If a COUNTRY spatial variable is present and CountryData is built, project centroids
            country_vars = [s for s in explicit_spatial if s.resolution == Spatial.Resolution.COUNTRY]
            if country_vars:
                from seroepi.country import CountryData

                if CountryData.is_built():
                    cd = CountryData.get_instance()
                    c_var = country_vars[0]
                    iso_df = df_raw.select(c_var.iso3_expr(strict=True, country_data=cd).alias("_iso3")).join(
                        cd.data.select(["iso3", "centroid_lat", "centroid_lon"]),
                        left_on="_iso3",
                        right_on="iso3",
                        how="left",
                    )
                    lat_arr = iso_df["centroid_lat"].cast(pl.Float64, strict=False).fill_null(float("nan")).to_numpy()
                    lon_arr = iso_df["centroid_lon"].cast(pl.Float64, strict=False).fill_null(float("nan")).to_numpy()
                    return lat_arr, lon_arr

        return None

    def _filter_candidate_edges(
        self,
        *,
        df_raw: pl.DataFrame,
        df_proj: pl.DataFrame,
        r_idx: np.ndarray,
        c_idx: np.ndarray,
        g_dist: np.ndarray,
        strata_vars: list[Variable],
        explicit_spatial: list[Spatial],
        temporal_var: Temporal | None,
        ts_epoch_days: np.ndarray | None,
        has_comp_target: bool,
    ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
        """Filters candidate genomic edges by clonal/strata, spatial, and temporal rules."""
        m = len(r_idx)
        if m == 0:
            empty_f = np.array([], dtype=np.float64)
            return r_idx, c_idx, g_dist, empty_f, empty_f

        keep = np.ones(m, dtype=bool)
        t_dist = np.full(m, np.nan, dtype=np.float64)
        s_dist = np.full(m, np.nan, dtype=np.float64)

        # 1. Compositional target equality (e.g. target=ds.var("ST"))
        if has_comp_target and "_target_cat" in df_proj.columns:
            t_vals = df_proj["_target_cat"].cast(pl.String).to_numpy()
            t_valid = df_proj["_target_cat"].is_not_null().to_numpy()
            keep &= t_valid[r_idx] & t_valid[c_idx] & (t_vals[r_idx] == t_vals[c_idx])

        # 2. Non-spatial, non-temporal blocking strata equality (e.g. Taxonomy clone/ST)
        for s_var in strata_vars:
            if isinstance(s_var, (Spatial, Temporal)):
                continue
            col_vals = df_proj[s_var.column].cast(pl.String).to_numpy()
            col_valid = df_proj[s_var.column].is_not_null().to_numpy()
            keep &= col_valid[r_idx] & col_valid[c_idx] & (col_vals[r_idx] == col_vals[c_idx])

        # 3. Spatial constraints (Point Haversine km cutoff and/or Categorical Resolution equality)
        coords = self._resolve_coordinates_arrays(
            df_raw if df_raw.height == df_proj.height else df_proj, explicit_spatial
        )
        if coords is not None:
            lat_arr, lon_arr = coords
            s_dist = haversine_distance_km(lat_arr[r_idx], lon_arr[r_idx], lat_arr[c_idx], lon_arr[c_idx])
            if self.spatial_cutoff is not None:
                keep &= ~np.isnan(s_dist) & (s_dist <= self.spatial_cutoff)

        cat_spatial_vars = [s for s in explicit_spatial if s.resolution != Spatial.Resolution.POINT]
        # Apply categorical spatial equality unless overridden by a numeric spatial_cutoff on coordinates
        if cat_spatial_vars and (self.spatial_cutoff is None or coords is None):
            for sp_var in cat_spatial_vars:
                if sp_var.resolution == Spatial.Resolution.GLOBAL:
                    continue
                sp_vals = df_proj[sp_var.column].cast(pl.String).to_numpy()
                sp_valid = df_proj[sp_var.column].is_not_null().to_numpy()
                keep &= sp_valid[r_idx] & sp_valid[c_idx] & (sp_vals[r_idx] == sp_vals[c_idx])

        # 4. Temporal constraints (Explicit temporal_cutoff duration or Temporal.Resolution bucket match)
        if temporal_var is not None and ts_epoch_days is not None:
            raw_dt = ts_epoch_days[c_idx] - ts_epoch_days[r_idx]
            t_dist = raw_dt if self.directed else np.abs(raw_dt)

            if self.temporal_cutoff is not None:
                cutoff_days = parse_temporal_cutoff_days(self.temporal_cutoff)
                if self.directed:
                    keep &= ~np.isnan(raw_dt) & (raw_dt >= 0.0) & (raw_dt <= cutoff_days)
                else:
                    keep &= ~np.isnan(raw_dt) & (np.abs(raw_dt) <= cutoff_days)
            else:
                if temporal_var.resolution != Temporal.Resolution.UNKNOWN:
                    tm_vals = df_proj[temporal_var.column].cast(pl.String).to_numpy()
                    tm_valid = df_proj[temporal_var.column].is_not_null().to_numpy()
                    keep &= tm_valid[r_idx] & tm_valid[c_idx] & (tm_vals[r_idx] == tm_vals[c_idx])
                if self.directed:
                    keep &= ~np.isnan(raw_dt) & (raw_dt >= 0.0)

        return r_idx[keep], c_idx[keep], g_dist[keep], t_dist[keep], s_dist[keep]

    def _build_neighbor_structures(
        self,
        *,
        n_samples: int,
        r_idx: np.ndarray,
        c_idx: np.ndarray,
        g_dist: np.ndarray,
        t_dist: np.ndarray,
        s_dist: np.ndarray,
    ) -> None:
        """Builds per-isolate sorted neighbor arrays for fast `kneighbors` and `radius_neighbors` queries."""
        self._neighbor_indices: list[np.ndarray] = [np.array([], dtype=np.intp) for _ in range(n_samples)]
        self._neighbor_distances: list[np.ndarray] = [np.array([], dtype=np.float64) for _ in range(n_samples)]
        self._edge_r = r_idx
        self._edge_c = c_idx
        self._edge_g = g_dist
        self._edge_t = t_dist
        self._edge_s = s_dist

        if len(r_idx) == 0 or n_samples == 0:
            return

        # Lexicographical sort by (r_idx, g_dist, abs(t_dist), s_dist, c_idx)
        t_sort = np.nan_to_num(np.abs(t_dist), nan=np.inf)
        s_sort = np.nan_to_num(s_dist, nan=np.inf)
        order = np.lexsort((c_idx, s_sort, t_sort, g_dist, r_idx))

        r_sorted = r_idx[order]
        c_sorted = c_idx[order]
        g_sorted = g_dist[order]

        unique_r, split_pts = np.unique(r_sorted, return_index=True)
        c_groups = np.split(c_sorted, split_pts[1:])
        g_groups = np.split(g_sorted, split_pts[1:])

        for row_i, c_arr, g_arr in zip(unique_r, c_groups, g_groups, strict=True):
            self._neighbor_indices[int(row_i)] = c_arr
            self._neighbor_distances[int(row_i)] = g_arr

    def _build_transmission_outputs(
        self,
        *,
        df_proj: pl.DataFrame,
        id_col: str,
        strata_vars: list[Variable],
        target_var: Variable,
        resolved_target: Target | None,
        is_compositional: bool,
    ) -> None:
        """Computes connected transmission clusters (`labels_`), `pairs_`, `isolates_`, and `data_`."""
        n_samples = len(self.sample_ids_)
        strata_cols = [s.column for s in strata_vars]

        # 1. Filter valid edges to those within self.radius
        r_limit = float(self.radius) if self.radius is not None else float("inf")
        if len(self._edge_g) > 0:
            rad_mask = self._edge_g <= r_limit
            r_rad = self._edge_r[rad_mask]
            c_rad = self._edge_c[rad_mask]
            g_rad = self._edge_g[rad_mask]
            t_rad = self._edge_t[rad_mask]
            s_rad = self._edge_s[rad_mask]
        else:
            r_rad = np.array([], dtype=np.intp)
            c_rad = np.array([], dtype=np.intp)
            g_rad = np.array([], dtype=np.float64)
            t_rad = np.array([], dtype=np.float64)
            s_rad = np.array([], dtype=np.float64)

        # 2. Connected components for transmission clusters (weakly connected in directed mode)
        if n_samples == 0:
            self.labels_ = np.array([], dtype=np.int64)
            comp_sizes = np.array([], dtype=np.int64)
            cluster_names: list[str | None] = []
        else:
            if len(r_rad) > 0:
                sym_r = np.concatenate([r_rad, c_rad])
                sym_c = np.concatenate([c_rad, r_rad])
                adj = coo_array(
                    (np.ones(len(sym_r), dtype=np.int8), (sym_r, sym_c)),
                    shape=(n_samples, n_samples),
                ).tocsr()
                _, raw_labels = sp_connected_components(csgraph=adj, directed=False, return_labels=True)
            else:
                raw_labels = np.arange(n_samples, dtype=np.int32)

            counts = np.bincount(raw_labels)
            comp_sizes = counts[raw_labels].astype(np.int64)

            # Rank multi-isolate components (size >= 2) deterministically by (-size, first_index)
            multi_comps: list[tuple[int, int, int]] = []
            single_comps: list[tuple[int, int]] = []
            seen_comp: dict[int, int] = {}
            for idx, lbl in enumerate(raw_labels):
                lbl_int = int(lbl)
                if lbl_int not in seen_comp:
                    seen_comp[lbl_int] = idx
                    sz = int(counts[lbl_int])
                    if sz >= 2:
                        multi_comps.append((-sz, idx, lbl_int))
                    else:
                        single_comps.append((idx, lbl_int))

            multi_comps.sort()
            remap: dict[int, int] = {}
            for new_id, (_, _, old_lbl) in enumerate(multi_comps):
                remap[old_lbl] = new_id

            if self.include_singletons:
                offset = len(multi_comps)
                for s_idx, (_, old_lbl) in enumerate(single_comps):
                    remap[old_lbl] = offset + s_idx
            else:
                for _, old_lbl in single_comps:
                    remap[old_lbl] = -1

            self.labels_ = np.array([remap[int(lbl)] for lbl in raw_labels], dtype=np.int64)
            cluster_names = [f"{self.cluster_prefix}{cid + 1}" if cid >= 0 else None for cid in self.labels_]

        # 3. Compute isolate degrees, nearest neighbors, and build self.isolates_
        out_deg = (
            np.bincount(r_rad, minlength=n_samples).astype(np.int64) if n_samples > 0 else np.array([], dtype=np.int64)
        )
        in_deg = (
            np.bincount(c_rad, minlength=n_samples).astype(np.int64) if n_samples > 0 else np.array([], dtype=np.int64)
        )
        degree = in_deg + out_deg if self.directed else out_deg
        is_transmitted = (in_deg >= 1) if self.directed else (degree >= 1)

        nearest_id: list[str | None] = [None] * n_samples
        nearest_dist: list[float | None] = [None] * n_samples
        for i in range(n_samples):
            nbr_i = self._neighbor_indices[i]
            dist_i = self._neighbor_distances[i]
            if len(dist_i) > 0 and dist_i[0] <= r_limit:
                nearest_id[i] = str(self.sample_ids_[int(nbr_i[0])])
                nearest_dist[i] = float(dist_i[0])

        isolate_cols: dict[str, Any] = {
            id_col: pl.Series(id_col, self.sample_ids_, dtype=pl.String),
            "cluster": pl.Series("cluster", cluster_names, dtype=pl.String),
            "cluster_id": pl.Series("cluster_id", self.labels_, dtype=pl.Int64),
            "cluster_size": pl.Series("cluster_size", comp_sizes, dtype=pl.Int64),
            "degree": pl.Series("degree", degree, dtype=pl.Int64),
        }
        if self.directed:
            isolate_cols["in_degree"] = pl.Series("in_degree", in_deg, dtype=pl.Int64)
            isolate_cols["out_degree"] = pl.Series("out_degree", out_deg, dtype=pl.Int64)
        isolate_cols["nearest_neighbor"] = pl.Series("nearest_neighbor", nearest_id, dtype=pl.String)
        isolate_cols["nearest_distance"] = pl.Series("nearest_distance", nearest_dist, dtype=pl.Float64)
        isolate_cols["is_transmitted"] = pl.Series("is_transmitted", is_transmitted, dtype=pl.Boolean)

        for col in strata_cols:
            if col in df_proj.columns:
                isolate_cols[col] = df_proj[col]
        if is_compositional and "_target_cat" in df_proj.columns:
            isolate_cols["target"] = df_proj["_target_cat"].cast(pl.String)

        self.isolates_ = pl.DataFrame(isolate_cols)

        # 4. Build self.pairs_ DataFrame (canonical undirected pairs if directed=False)
        if len(r_rad) > 0:
            if not self.directed:
                canon = r_rad < c_rad
                p_r = r_rad[canon]
                p_c = c_rad[canon]
                p_g = g_rad[canon]
                p_t = t_rad[canon]
                p_s = s_rad[canon]
            else:
                p_r, p_c, p_g, p_t, p_s = r_rad, c_rad, g_rad, t_rad, s_rad

            pair_cols: dict[str, Any] = {
                "source": pl.Series("source", self.sample_ids_[p_r], dtype=pl.String),
                "target": pl.Series("target", self.sample_ids_[p_c], dtype=pl.String),
                "genomic_distance": pl.Series("genomic_distance", p_g, dtype=pl.Float64),
                "temporal_distance_days": pl.Series(
                    "temporal_distance_days",
                    [None if np.isnan(x) else float(x) for x in p_t],
                    dtype=pl.Float64,
                ),
                "spatial_distance_km": pl.Series(
                    "spatial_distance_km",
                    [None if np.isnan(x) else float(x) for x in p_s],
                    dtype=pl.Float64,
                ),
                "cluster": pl.Series("cluster", [cluster_names[int(i)] for i in p_r], dtype=pl.String),
            }
            for col in strata_cols:
                if col in df_proj.columns:
                    pair_cols[col] = df_proj[col].gather(p_r)
            self.pairs_ = pl.DataFrame(pair_cols)
        else:
            empty_pair_schema: dict[str, Any] = {
                "source": pl.String,
                "target": pl.String,
                "genomic_distance": pl.Float64,
                "temporal_distance_days": pl.Float64,
                "spatial_distance_km": pl.Float64,
                "cluster": pl.String,
            }
            for col in strata_cols:
                if col in df_proj.columns:
                    empty_pair_schema[col] = df_proj.schema[col]
            self.pairs_ = pl.DataFrame(schema=empty_pair_schema)

        # 5. Build stratified summary DataFrame self.data_
        self._build_stratified_summary(
            strata_vars=strata_vars,
            target_var=target_var,
            resolved_target=resolved_target,
            is_compositional=is_compositional,
        )

    def _build_stratified_summary(
        self,
        *,
        strata_vars: list[Variable],
        target_var: Variable,
        resolved_target: Target | None,
        is_compositional: bool,
    ) -> None:
        """Aggregates isolate and pair transmission metrics into `self.data_`."""
        strata_cols = [s.column for s in strata_vars]
        target_default_name = resolved_target.name if resolved_target is not None else "transmission"

        iso_df = self.isolates_
        if not is_compositional:
            iso_df = iso_df.with_columns(pl.lit(target_default_name).alias("target"))

        group_cols = [*strata_cols, "target"]

        if iso_df.height == 0:
            empty_schema: dict[str, Any] = {s.column: s.dtype for s in strata_vars}
            empty_schema.update(
                {
                    "target": pl.String,
                    "event": pl.Int64,
                    "n": pl.Int64,
                    "n_clusters": pl.Int64,
                    "mean_cluster_size": pl.Float64,
                    "max_cluster_size": pl.Int64,
                    "n_pairs": pl.Int64,
                    "mean_distance": pl.Float64,
                }
            )
            self.data_ = pl.DataFrame(schema=empty_schema)
        else:
            # Compute per-group cluster stats from unique multi-isolate clusters (cluster_id >= 0)
            multi_iso = iso_df.filter(pl.col("cluster_id") >= 0)
            if multi_iso.height > 0:
                cluster_sizes_by_grp = (
                    multi_iso.group_by([*group_cols, "cluster_id"])
                    .agg(pl.len().cast(pl.Int64).alias("_c_size"))
                    .group_by(group_cols)
                    .agg(
                        pl.len().cast(pl.Int64).alias("n_clusters"),
                        pl.col("_c_size").mean().cast(pl.Float64).alias("mean_cluster_size"),
                        pl.col("_c_size").max().cast(pl.Int64).alias("max_cluster_size"),
                    )
                )
            else:
                cluster_sizes_by_grp = None

            # Compute per-group pair counts and mean genomic distance
            if self.pairs_.height > 0:
                pairs_with_tgt = self.pairs_
                if is_compositional:
                    # Join target category from source isolate
                    src_tgt = iso_df.select([pl.col(self.id_col_).alias("source"), pl.col("target")])
                    pairs_with_tgt = pairs_with_tgt.join(src_tgt, on="source", how="left")
                else:
                    pairs_with_tgt = pairs_with_tgt.with_columns(pl.lit(target_default_name).alias("target"))

                pair_stats_by_grp = pairs_with_tgt.group_by(group_cols).agg(
                    pl.len().cast(pl.Int64).alias("n_pairs"),
                    pl.col("genomic_distance").mean().cast(pl.Float64).alias("mean_distance"),
                )
            else:
                pair_stats_by_grp = None

            if is_compositional:
                # For compositional target, denominator n is the stratum total (matching Aggregator contract)
                strata_n = (
                    iso_df.group_by(strata_cols).agg(pl.len().cast(pl.Int64).alias("n"))
                    if strata_cols
                    else iso_df.select(pl.len().cast(pl.Int64).alias("n"))
                )
                events_df = iso_df.group_by(group_cols, maintain_order=True).agg(
                    pl.col("is_transmitted").cast(pl.Int64).sum().alias("event"),
                )
                base_summary = (
                    events_df.join(strata_n, on=strata_cols, how="left")
                    if strata_cols
                    else events_df.join(strata_n, how="cross")
                )
            else:
                base_summary = iso_df.group_by(group_cols, maintain_order=True).agg(
                    pl.col("is_transmitted").cast(pl.Int64).sum().alias("event"),
                    pl.len().cast(pl.Int64).alias("n"),
                )

            if cluster_sizes_by_grp is not None:
                base_summary = base_summary.join(cluster_sizes_by_grp, on=group_cols, how="left")
            else:
                base_summary = base_summary.with_columns(
                    pl.lit(0, dtype=pl.Int64).alias("n_clusters"),
                    pl.lit(0.0, dtype=pl.Float64).alias("mean_cluster_size"),
                    pl.lit(0, dtype=pl.Int64).alias("max_cluster_size"),
                )

            if pair_stats_by_grp is not None:
                base_summary = base_summary.join(pair_stats_by_grp, on=group_cols, how="left")
            else:
                base_summary = base_summary.with_columns(
                    pl.lit(0, dtype=pl.Int64).alias("n_pairs"),
                    pl.lit(None, dtype=pl.Float64).alias("mean_distance"),
                )

            if self.pad_zeros and strata_cols:
                unique_strata = [iso_df[s].drop_nulls().unique(maintain_order=True).to_list() for s in strata_cols]
                unique_targets = iso_df["target"].drop_nulls().unique(maintain_order=True).to_list()
                grid_rows = list(itertools.product(*unique_strata, unique_targets))
                if grid_rows:
                    grid_df = pl.DataFrame(grid_rows, schema=group_cols, orient="row")
                    if is_compositional:
                        strata_n = iso_df.group_by(strata_cols).agg(pl.len().cast(pl.Int64).alias("n"))
                        base_summary = grid_df.join(base_summary.drop("n"), on=group_cols, how="left").join(
                            strata_n, on=strata_cols, how="left"
                        )
                    else:
                        base_summary = grid_df.join(base_summary, on=group_cols, how="left")

            self.data_ = base_summary.with_columns(
                pl.col("event").fill_null(0).cast(pl.Int64),
                pl.col("n").fill_null(0).cast(pl.Int64),
                pl.col("n_clusters").fill_null(0).cast(pl.Int64),
                pl.col("mean_cluster_size").fill_null(0.0).cast(pl.Float64),
                pl.col("max_cluster_size").fill_null(0).cast(pl.Int64),
                pl.col("n_pairs").fill_null(0).cast(pl.Int64),
            ).select(
                [
                    *strata_cols,
                    "target",
                    "event",
                    "n",
                    "n_clusters",
                    "mean_cluster_size",
                    "max_cluster_size",
                    "n_pairs",
                    "mean_distance",
                ]
            )

        out_vars: dict[str, Variable] = {s.column: s for s in strata_vars}
        out_vars["target"] = target_var
        out_vars["event"] = Count("event", title="Transmitted Isolates", is_denominator=False)
        out_vars["n"] = Count("n", title="Total Isolates", is_denominator=True)
        out_vars["n_clusters"] = Count("n_clusters", title="Transmission Clusters", is_denominator=False)
        out_vars["mean_cluster_size"] = Metric("mean_cluster_size", title="Mean Cluster Size")
        out_vars["max_cluster_size"] = Count("max_cluster_size", title="Max Cluster Size", is_denominator=False)
        out_vars["n_pairs"] = Count("n_pairs", title="Transmission Pairs", is_denominator=False)
        out_vars["mean_distance"] = Metric("mean_distance", title="Mean Genomic Distance")

        self.variables_ = out_vars
        self.target_ = target_var
        self.strata_ = tuple(strata_vars)
        self.adjust_for_ = None
        self.is_compositional_ = is_compositional
        self.is_padded_ = bool(self.pad_zeros)

    # -------------------------------------------------------------------------
    # Scikit-Learn NearestNeighbors Graph & Query API
    # -------------------------------------------------------------------------

    def radius_neighbors(
        self,
        X: Dataset | Any = None,
        radius: float | None = None,
        return_distance: bool = True,
        sort_results: bool = False,
    ) -> tuple[np.ndarray, np.ndarray] | np.ndarray:
        """Finds all transmission neighbors within a given genomic ``radius`` for each isolate.

        Respects all configured ``strata``, ``Spatial``, and ``Temporal`` cutoffs/resolutions.

        Args:
            X: Optional query ``Dataset`` or feature matrix. Defaults to the training data when ``None``.
            radius: Optional genomic distance threshold override. Defaults to ``self.radius``.
            return_distance: Whether to return both ``(distances, indices)`` object arrays.
            sort_results: Whether to sort each isolate's neighbors in ascending distance order.

        Returns:
            A 2-tuple ``(neigh_dist, neigh_ind)`` of 1D object arrays when ``return_distance=True``,
            or ``neigh_ind`` alone when ``return_distance=False``.

        Raises:
            RuntimeError: If the estimator has not been fitted yet.
        """
        self.check_is_fitted()
        if X is not None and not isinstance(X, Dataset) and not getattr(self, "_dataset_mode", True):
            return super().radius_neighbors(
                X=X,
                radius=radius,
                return_distance=return_distance,
                sort_results=sort_results,
            )

        target_est = self if X is None else copy.copy(self).fit(X)
        raw_r = self.radius if radius is None else radius
        r_val = float(raw_r) if raw_r is not None else float("inf")
        n = len(target_est._neighbor_indices)

        neigh_dist_list: list[np.ndarray] = []
        neigh_ind_list: list[np.ndarray] = []
        for i in range(n):
            dists = target_est._neighbor_distances[i]
            inds = target_est._neighbor_indices[i]
            if len(dists) == 0:
                neigh_dist_list.append(np.array([], dtype=np.float64))
                neigh_ind_list.append(np.array([], dtype=np.intp))
                continue
            # _neighbor_distances[i] is already sorted in ascending order
            cutoff_idx = int(np.searchsorted(dists, r_val, side="right"))
            d_sub = dists[:cutoff_idx].copy()
            i_sub = inds[:cutoff_idx].copy()
            if not sort_results and len(i_sub) > 1:
                idx_order = np.argsort(i_sub)
                i_sub = i_sub[idx_order]
                d_sub = d_sub[idx_order]
            neigh_dist_list.append(d_sub)
            neigh_ind_list.append(i_sub)

        neigh_ind = np.empty(n, dtype=object)
        neigh_ind[:] = neigh_ind_list
        if not return_distance:
            return neigh_ind

        neigh_dist = np.empty(n, dtype=object)
        neigh_dist[:] = neigh_dist_list
        return neigh_dist, neigh_ind

    def radius_neighbors_graph(
        self,
        X: Dataset | Any = None,
        radius: float | None = None,
        mode: Literal["connectivity", "distance"] = "connectivity",
        sort_results: bool = False,
    ) -> csr_matrix:
        """Computes the (weighted) sparse graph of isolate transmission neighbors within ``radius``.

        Args:
            X: Optional query ``Dataset`` or feature matrix. Defaults to the training data when ``None``.
            radius: Optional genomic distance threshold override. Defaults to ``self.radius``.
            mode: Edge weight mode (``"connectivity"`` for binary ``1.0`` weights or ``"distance"`` for genomic distances).
            sort_results: Whether to sort column indices by distance within each CSR row.

        Returns:
            A square SciPy ``csr_matrix`` of shape ``(n_samples, n_samples)``.

        Raises:
            RuntimeError: If the estimator has not been fitted yet.
            ValueError: If ``mode`` is not ``"connectivity"`` or ``"distance"``.
        """
        self.check_is_fitted()
        if mode not in ("connectivity", "distance"):
            raise ValueError(f"Unsupported mode={mode!r}. Expected 'connectivity' or 'distance'.")

        dists, inds = self.radius_neighbors(
            X=X,
            radius=radius,
            return_distance=True,
            sort_results=sort_results,
        )
        n = len(inds)
        counts = np.array([len(ix) for ix in inds], dtype=np.intp)
        indptr = np.zeros(n + 1, dtype=np.intp)
        np.cumsum(counts, out=indptr[1:])
        if indptr[-1] == 0:
            return csr_matrix((n, n), dtype=np.float64)

        indices = np.concatenate(list(inds)).astype(np.intp)
        if mode == "connectivity":
            data = np.ones(len(indices), dtype=np.float64)
        else:
            data = np.concatenate(list(dists)).astype(np.float64)

        return csr_matrix((data, indices, indptr), shape=(n, n))

    def kneighbors(
        self,
        X: Dataset | Any = None,
        n_neighbors: int | None = None,
        return_distance: bool = True,
    ) -> tuple[np.ndarray, np.ndarray] | np.ndarray:
        """Finds the K-nearest transmission neighbors for each isolate.

        Isolates with fewer than ``n_neighbors`` valid transmission candidates within ``self.radius``
        have remaining slots padded with ``-1`` in ``indices`` and ``np.nan`` in ``distances``.

        Args:
            X: Optional query ``Dataset`` or feature matrix. Defaults to the training data when ``None``.
            n_neighbors: Optional number of neighbors ``k`` to retrieve per isolate. Defaults to ``self.n_neighbors``.
            return_distance: Whether to return ``(distances, indices)`` or ``indices`` only.

        Returns:
            A 2-tuple ``(dist_mat, ind_mat)`` of shape ``(n_samples, n_neighbors)`` when ``return_distance=True``,
            or ``ind_mat`` alone when ``return_distance=False``.

        Raises:
            RuntimeError: If the estimator has not been fitted yet.
            ValueError: If ``n_neighbors <= 0``.
        """
        self.check_is_fitted()
        if X is not None and not isinstance(X, Dataset) and not getattr(self, "_dataset_mode", True):
            return super().kneighbors(X=X, n_neighbors=n_neighbors, return_distance=return_distance)

        target_est = self if X is None else copy.copy(self).fit(X)
        raw_k = self.n_neighbors if n_neighbors is None else n_neighbors
        k = int(raw_k) if raw_k is not None else 5
        if k <= 0:
            raise ValueError(f"Expected n_neighbors > 0, got {k}.")

        n = len(target_est._neighbor_indices)
        r_val = float(self.radius) if self.radius is not None else np.inf

        dist_mat = np.full((n, k), np.nan, dtype=np.float64)
        ind_mat = np.full((n, k), -1, dtype=np.intp)

        for i in range(n):
            dists = target_est._neighbor_distances[i]
            inds = target_est._neighbor_indices[i]
            if len(dists) == 0:
                continue
            cutoff_idx = int(np.searchsorted(dists, r_val, side="right"))
            take = min(k, cutoff_idx)
            if take > 0:
                dist_mat[i, :take] = dists[:take]
                ind_mat[i, :take] = inds[:take]

        if return_distance:
            return dist_mat, ind_mat
        return ind_mat

    def kneighbors_graph(
        self,
        X: Dataset | Any = None,
        n_neighbors: int | None = None,
        mode: Literal["connectivity", "distance"] = "connectivity",
    ) -> csr_matrix:
        """Computes the (weighted) sparse graph of K-nearest transmission neighbors.

        Args:
            X: Optional query ``Dataset`` or feature matrix. Defaults to the training data when ``None``.
            n_neighbors: Optional number of neighbors ``k`` per isolate. Defaults to ``self.n_neighbors``.
            mode: Edge weight mode (``"connectivity"`` for binary ``1.0`` weights or ``"distance"`` for genomic distances).

        Returns:
            A square SciPy ``csr_matrix`` of shape ``(n_samples, n_samples)``.

        Raises:
            RuntimeError: If the estimator has not been fitted yet.
            ValueError: If ``mode`` is not ``"connectivity"`` or ``"distance"``.
        """
        self.check_is_fitted()
        if mode not in ("connectivity", "distance"):
            raise ValueError(f"Unsupported mode={mode!r}. Expected 'connectivity' or 'distance'.")

        dist_mat, ind_mat = self.kneighbors(X=X, n_neighbors=n_neighbors, return_distance=True)
        n = ind_mat.shape[0]
        row_list: list[np.ndarray] = []
        col_list: list[np.ndarray] = []
        val_list: list[np.ndarray] = []

        for i in range(n):
            valid = ind_mat[i] >= 0
            if np.any(valid):
                cols = ind_mat[i, valid]
                row_list.append(np.full(len(cols), i, dtype=np.intp))
                col_list.append(cols)
                if mode == "connectivity":
                    val_list.append(np.ones(len(cols), dtype=np.float64))
                else:
                    val_list.append(dist_mat[i, valid])

        if not row_list:
            return csr_matrix((n, n), dtype=np.float64)

        rows = np.concatenate(row_list)
        cols = np.concatenate(col_list)
        vals = np.concatenate(val_list)
        return coo_array((vals, (rows, cols)), shape=(n, n)).tocsr()

    def fit_predict(self, X: Dataset | Any, y: Any = None) -> np.ndarray:
        """Fits the transmission model on ``X`` and returns integer cluster ``labels_``.

        Args:
            X: A ``Dataset`` with attached ``GenomicDistances``, or a 2D precomputed/feature matrix.
            y: Ignored; present for scikit-learn compatibility.

        Returns:
            1D int64 NumPy array of cluster labels for each isolate.
        """
        return self.fit(X, y).labels_

    def to_dataset(
        self,
        dataset: Dataset,
        name: str = "transmission_cluster",
    ) -> Dataset:
        """Returns a new ``Dataset`` with the fitted transmission cluster labels joined as a ``TRANSMISSION_CLONE`` column.

        Args:
            dataset: Target ``Dataset`` to enrich with fitted transmission cluster labels.
            name: Column name for the new ``Taxonomy(resolution=TRANSMISSION_CLONE)`` variable.

        Returns:
            A new ``Dataset`` containing the joined ``name`` column and registered ``Taxonomy`` variable.

        Raises:
            RuntimeError: If the estimator has not been fitted yet.
        """
        self.check_is_fitted()
        id_col = dataset.identifier.column
        cluster_df = self.isolates_.select(
            [
                pl.col(self.id_col_).alias(id_col),
                pl.col("cluster").cast(pl.Categorical).alias(name),
            ]
        )
        lf = dataset.data.lazy()
        if name in dataset.data.columns:
            lf = lf.drop(name)
        new_data = lf.join(cluster_df.lazy(), on=id_col, how="left").collect()

        new_vars = dict(dataset.variables)
        new_vars[name] = Taxonomy(
            column=name,
            title=name,
            resolution=Taxonomy.Resolution.TRANSMISSION_CLONE,
            dtype=pl.Categorical(),
        )
        return Dataset(
            data=new_data,
            name=dataset.name,
            variables=new_vars,
            distances=dataset.distances,
        )

fit

fit(X: Dataset | Any, y: Any = None) -> Self

Fits the transmission model on a Dataset (or precomputed/feature matrix X).

Parameters:

Name Type Description Default
X Dataset | Any

A Dataset with attached GenomicDistances, or a 2D precomputed/feature matrix.

required
y Any

Ignored; present for scikit-learn compatibility.

None

Returns:

Type Description
Self

The fitted TransmissionEstimator instance (self).

Raises:

Type Description
ValueError

If X is a Dataset without attached GenomicDistances.

Source code in src/seroepi/estimators/transmission.py
def fit(self, X: Dataset | Any, y: Any = None) -> Self:
    """Fits the transmission model on a ``Dataset`` (or precomputed/feature matrix ``X``).

    Args:
        X: A ``Dataset`` with attached ``GenomicDistances``, or a 2D precomputed/feature matrix.
        y: Ignored; present for scikit-learn compatibility.

    Returns:
        The fitted ``TransmissionEstimator`` instance (``self``).

    Raises:
        ValueError: If ``X`` is a ``Dataset`` without attached ``GenomicDistances``.
    """
    if not isinstance(X, Dataset):
        return self._fit_raw_matrix(X, y)

    if X.distances is None:
        raise ValueError(
            "Cannot fit TransmissionEstimator on a Dataset without attached GenomicDistances. "
            "Call dataset.with_distances(...) first."
        )

    self._dataset_mode = True
    ds = X.qc() if self.filter_qc else X
    df = ds.data
    id_col = ds.identifier.column
    self.id_col_ = id_col

    # 1. Resolve Spatial & Temporal variables and Resolution overrides
    sp_res_override = self._coerce_spatial_resolution(self.spatial_resolution)
    tm_res_override = self._coerce_temporal_resolution(self.temporal_resolution)

    strata_vars: list[Variable] = []
    for s in self.strata:
        if isinstance(s, Spatial) and sp_res_override is not None and s.resolution != Spatial.Resolution.POINT:
            strata_vars.append(s.copy(resolution=sp_res_override))
        elif isinstance(s, Temporal) and tm_res_override is not None:
            strata_vars.append(s.copy(resolution=tm_res_override))
        else:
            strata_vars.append(s)

    explicit_spatial = list(self.spatial)
    if not explicit_spatial:
        explicit_spatial = [s for s in strata_vars if isinstance(s, Spatial)]
    else:
        explicit_spatial = [
            s.copy(resolution=sp_res_override)
            if sp_res_override is not None and s.resolution != Spatial.Resolution.POINT
            else s
            for s in explicit_spatial
        ]

    temporal_var = self.temporal
    if temporal_var is None:
        temporal_var = next((s for s in strata_vars if isinstance(s, Temporal)), None)
    elif tm_res_override is not None:
        temporal_var = temporal_var.copy(resolution=tm_res_override)

    # 2. Resolve optional Target
    resolved_target: Target | None = None
    is_compositional = False
    if self.target is not None:
        resolved_target = Target.coerce(self.target)
        is_compositional = resolved_target.is_compositional
        if not is_compositional:
            # Binary target: restrict transmission evaluation to target-positive isolates
            df = df.filter(resolved_target.to_expr())
        target_var = Trait(column="target", title=resolved_target.title, compositional=is_compositional)
    else:
        target_var = Trait(column="target", title="Transmission", compositional=False)

    n_samples = df.height
    sample_ids = df[id_col].cast(pl.String).to_numpy()
    self.sample_ids_ = sample_ids
    self.n_samples_fit_ = n_samples
    self.n_features_in_ = n_samples

    # 3. Project strata, spatial, and temporal columns on df
    proj_exprs: list[pl.Expr] = []
    strata_cols = [s.column for s in strata_vars]
    for s in strata_vars:
        if isinstance(s, Spatial) and s.resolution == Spatial.Resolution.GLOBAL:
            proj_exprs.append(pl.lit("Global").alias(s.column))
        else:
            proj_exprs.append(s.to_expr())

    for sp_var in explicit_spatial:
        if sp_var.column not in strata_cols and sp_var.resolution != Spatial.Resolution.POINT:
            if sp_var.resolution == Spatial.Resolution.GLOBAL:
                proj_exprs.append(pl.lit("Global").alias(sp_var.column))
            else:
                proj_exprs.append(sp_var.to_expr())

    if temporal_var is not None and temporal_var.column not in strata_cols:
        proj_exprs.append(temporal_var.to_expr())

    if temporal_var is not None:
        # Parse raw timestamps BEFORE truncating temporal column in df
        parsed_ts_series = df.select(temporal_var.parse_expr().alias("_parsed_ts"))["_parsed_ts"]
        ts_epoch_days = (
            (parsed_ts_series.dt.epoch(time_unit="us").cast(pl.Float64) / (86400.0 * 1_000_000.0))
            .fill_null(float("nan"))
            .to_numpy()
        )
    else:
        ts_epoch_days = None

    if resolved_target is not None and is_compositional:
        proj_exprs.append(resolved_target.to_category_expr().alias("_target_cat"))

    if proj_exprs:
        df_proj = df.with_columns(*proj_exprs)
    else:
        df_proj = df

    if resolved_target is not None and is_compositional:
        df_proj = df_proj.filter(pl.col("_target_cat").is_not_null())
        sample_ids = df_proj[id_col].cast(pl.String).to_numpy()
        if ts_epoch_days is not None:
            valid_mask = df.select(resolved_target.to_category_expr().is_not_null().alias("_m"))["_m"].to_numpy()
            ts_epoch_days = ts_epoch_days[valid_mask]
        n_samples = df_proj.height
        self.sample_ids_ = sample_ids
        self.n_samples_fit_ = n_samples
        self.n_features_in_ = n_samples

    # 4. Extract candidate off-diagonal genomic pairs aligned to sample_ids
    r_idx, c_idx, g_dist = self._extract_aligned_genomic_edges(ds, sample_ids)

    # 5. Apply Clonal/Strata, Spatial, and Temporal constraints to candidate edges
    r_idx, c_idx, g_dist, t_dist, s_dist = self._filter_candidate_edges(
        df_raw=df,
        df_proj=df_proj,
        r_idx=r_idx,
        c_idx=c_idx,
        g_dist=g_dist,
        strata_vars=strata_vars,
        explicit_spatial=explicit_spatial,
        temporal_var=temporal_var,
        ts_epoch_days=ts_epoch_days,
        has_comp_target=bool(resolved_target is not None and is_compositional),
    )

    # 6. Store sorted per-isolate valid neighbor structures (across all valid genomic distances)
    self._build_neighbor_structures(
        n_samples=n_samples,
        r_idx=r_idx,
        c_idx=c_idx,
        g_dist=g_dist,
        t_dist=t_dist,
        s_dist=s_dist,
    )

    # 7. Threshold by self.radius to build clusters (labels_), isolates_, pairs_, and stratified data_
    self._build_transmission_outputs(
        df_proj=df_proj,
        id_col=id_col,
        strata_vars=strata_vars,
        target_var=target_var,
        resolved_target=resolved_target,
        is_compositional=is_compositional,
    )

    self.is_fitted_ = True
    return self

radius_neighbors

radius_neighbors(
    X: Dataset | Any = None,
    radius: float | None = None,
    return_distance: bool = True,
    sort_results: bool = False,
) -> tuple[ndarray, ndarray] | ndarray

Finds all transmission neighbors within a given genomic radius for each isolate.

Respects all configured strata, Spatial, and Temporal cutoffs/resolutions.

Parameters:

Name Type Description Default
X Dataset | Any

Optional query Dataset or feature matrix. Defaults to the training data when None.

None
radius float | None

Optional genomic distance threshold override. Defaults to self.radius.

None
return_distance bool

Whether to return both (distances, indices) object arrays.

True
sort_results bool

Whether to sort each isolate's neighbors in ascending distance order.

False

Returns:

Type Description
tuple[ndarray, ndarray] | ndarray

A 2-tuple (neigh_dist, neigh_ind) of 1D object arrays when return_distance=True,

tuple[ndarray, ndarray] | ndarray

or neigh_ind alone when return_distance=False.

Raises:

Type Description
RuntimeError

If the estimator has not been fitted yet.

Source code in src/seroepi/estimators/transmission.py
def radius_neighbors(
    self,
    X: Dataset | Any = None,
    radius: float | None = None,
    return_distance: bool = True,
    sort_results: bool = False,
) -> tuple[np.ndarray, np.ndarray] | np.ndarray:
    """Finds all transmission neighbors within a given genomic ``radius`` for each isolate.

    Respects all configured ``strata``, ``Spatial``, and ``Temporal`` cutoffs/resolutions.

    Args:
        X: Optional query ``Dataset`` or feature matrix. Defaults to the training data when ``None``.
        radius: Optional genomic distance threshold override. Defaults to ``self.radius``.
        return_distance: Whether to return both ``(distances, indices)`` object arrays.
        sort_results: Whether to sort each isolate's neighbors in ascending distance order.

    Returns:
        A 2-tuple ``(neigh_dist, neigh_ind)`` of 1D object arrays when ``return_distance=True``,
        or ``neigh_ind`` alone when ``return_distance=False``.

    Raises:
        RuntimeError: If the estimator has not been fitted yet.
    """
    self.check_is_fitted()
    if X is not None and not isinstance(X, Dataset) and not getattr(self, "_dataset_mode", True):
        return super().radius_neighbors(
            X=X,
            radius=radius,
            return_distance=return_distance,
            sort_results=sort_results,
        )

    target_est = self if X is None else copy.copy(self).fit(X)
    raw_r = self.radius if radius is None else radius
    r_val = float(raw_r) if raw_r is not None else float("inf")
    n = len(target_est._neighbor_indices)

    neigh_dist_list: list[np.ndarray] = []
    neigh_ind_list: list[np.ndarray] = []
    for i in range(n):
        dists = target_est._neighbor_distances[i]
        inds = target_est._neighbor_indices[i]
        if len(dists) == 0:
            neigh_dist_list.append(np.array([], dtype=np.float64))
            neigh_ind_list.append(np.array([], dtype=np.intp))
            continue
        # _neighbor_distances[i] is already sorted in ascending order
        cutoff_idx = int(np.searchsorted(dists, r_val, side="right"))
        d_sub = dists[:cutoff_idx].copy()
        i_sub = inds[:cutoff_idx].copy()
        if not sort_results and len(i_sub) > 1:
            idx_order = np.argsort(i_sub)
            i_sub = i_sub[idx_order]
            d_sub = d_sub[idx_order]
        neigh_dist_list.append(d_sub)
        neigh_ind_list.append(i_sub)

    neigh_ind = np.empty(n, dtype=object)
    neigh_ind[:] = neigh_ind_list
    if not return_distance:
        return neigh_ind

    neigh_dist = np.empty(n, dtype=object)
    neigh_dist[:] = neigh_dist_list
    return neigh_dist, neigh_ind

radius_neighbors_graph

radius_neighbors_graph(
    X: Dataset | Any = None,
    radius: float | None = None,
    mode: Literal[
        "connectivity", "distance"
    ] = "connectivity",
    sort_results: bool = False,
) -> csr_matrix

Computes the (weighted) sparse graph of isolate transmission neighbors within radius.

Parameters:

Name Type Description Default
X Dataset | Any

Optional query Dataset or feature matrix. Defaults to the training data when None.

None
radius float | None

Optional genomic distance threshold override. Defaults to self.radius.

None
mode Literal['connectivity', 'distance']

Edge weight mode ("connectivity" for binary 1.0 weights or "distance" for genomic distances).

'connectivity'
sort_results bool

Whether to sort column indices by distance within each CSR row.

False

Returns:

Type Description
csr_matrix

A square SciPy csr_matrix of shape (n_samples, n_samples).

Raises:

Type Description
RuntimeError

If the estimator has not been fitted yet.

ValueError

If mode is not "connectivity" or "distance".

Source code in src/seroepi/estimators/transmission.py
def radius_neighbors_graph(
    self,
    X: Dataset | Any = None,
    radius: float | None = None,
    mode: Literal["connectivity", "distance"] = "connectivity",
    sort_results: bool = False,
) -> csr_matrix:
    """Computes the (weighted) sparse graph of isolate transmission neighbors within ``radius``.

    Args:
        X: Optional query ``Dataset`` or feature matrix. Defaults to the training data when ``None``.
        radius: Optional genomic distance threshold override. Defaults to ``self.radius``.
        mode: Edge weight mode (``"connectivity"`` for binary ``1.0`` weights or ``"distance"`` for genomic distances).
        sort_results: Whether to sort column indices by distance within each CSR row.

    Returns:
        A square SciPy ``csr_matrix`` of shape ``(n_samples, n_samples)``.

    Raises:
        RuntimeError: If the estimator has not been fitted yet.
        ValueError: If ``mode`` is not ``"connectivity"`` or ``"distance"``.
    """
    self.check_is_fitted()
    if mode not in ("connectivity", "distance"):
        raise ValueError(f"Unsupported mode={mode!r}. Expected 'connectivity' or 'distance'.")

    dists, inds = self.radius_neighbors(
        X=X,
        radius=radius,
        return_distance=True,
        sort_results=sort_results,
    )
    n = len(inds)
    counts = np.array([len(ix) for ix in inds], dtype=np.intp)
    indptr = np.zeros(n + 1, dtype=np.intp)
    np.cumsum(counts, out=indptr[1:])
    if indptr[-1] == 0:
        return csr_matrix((n, n), dtype=np.float64)

    indices = np.concatenate(list(inds)).astype(np.intp)
    if mode == "connectivity":
        data = np.ones(len(indices), dtype=np.float64)
    else:
        data = np.concatenate(list(dists)).astype(np.float64)

    return csr_matrix((data, indices, indptr), shape=(n, n))

kneighbors

kneighbors(
    X: Dataset | Any = None,
    n_neighbors: int | None = None,
    return_distance: bool = True,
) -> tuple[ndarray, ndarray] | ndarray

Finds the K-nearest transmission neighbors for each isolate.

Isolates with fewer than n_neighbors valid transmission candidates within self.radius have remaining slots padded with -1 in indices and np.nan in distances.

Parameters:

Name Type Description Default
X Dataset | Any

Optional query Dataset or feature matrix. Defaults to the training data when None.

None
n_neighbors int | None

Optional number of neighbors k to retrieve per isolate. Defaults to self.n_neighbors.

None
return_distance bool

Whether to return (distances, indices) or indices only.

True

Returns:

Type Description
tuple[ndarray, ndarray] | ndarray

A 2-tuple (dist_mat, ind_mat) of shape (n_samples, n_neighbors) when return_distance=True,

tuple[ndarray, ndarray] | ndarray

or ind_mat alone when return_distance=False.

Raises:

Type Description
RuntimeError

If the estimator has not been fitted yet.

ValueError

If n_neighbors <= 0.

Source code in src/seroepi/estimators/transmission.py
def kneighbors(
    self,
    X: Dataset | Any = None,
    n_neighbors: int | None = None,
    return_distance: bool = True,
) -> tuple[np.ndarray, np.ndarray] | np.ndarray:
    """Finds the K-nearest transmission neighbors for each isolate.

    Isolates with fewer than ``n_neighbors`` valid transmission candidates within ``self.radius``
    have remaining slots padded with ``-1`` in ``indices`` and ``np.nan`` in ``distances``.

    Args:
        X: Optional query ``Dataset`` or feature matrix. Defaults to the training data when ``None``.
        n_neighbors: Optional number of neighbors ``k`` to retrieve per isolate. Defaults to ``self.n_neighbors``.
        return_distance: Whether to return ``(distances, indices)`` or ``indices`` only.

    Returns:
        A 2-tuple ``(dist_mat, ind_mat)`` of shape ``(n_samples, n_neighbors)`` when ``return_distance=True``,
        or ``ind_mat`` alone when ``return_distance=False``.

    Raises:
        RuntimeError: If the estimator has not been fitted yet.
        ValueError: If ``n_neighbors <= 0``.
    """
    self.check_is_fitted()
    if X is not None and not isinstance(X, Dataset) and not getattr(self, "_dataset_mode", True):
        return super().kneighbors(X=X, n_neighbors=n_neighbors, return_distance=return_distance)

    target_est = self if X is None else copy.copy(self).fit(X)
    raw_k = self.n_neighbors if n_neighbors is None else n_neighbors
    k = int(raw_k) if raw_k is not None else 5
    if k <= 0:
        raise ValueError(f"Expected n_neighbors > 0, got {k}.")

    n = len(target_est._neighbor_indices)
    r_val = float(self.radius) if self.radius is not None else np.inf

    dist_mat = np.full((n, k), np.nan, dtype=np.float64)
    ind_mat = np.full((n, k), -1, dtype=np.intp)

    for i in range(n):
        dists = target_est._neighbor_distances[i]
        inds = target_est._neighbor_indices[i]
        if len(dists) == 0:
            continue
        cutoff_idx = int(np.searchsorted(dists, r_val, side="right"))
        take = min(k, cutoff_idx)
        if take > 0:
            dist_mat[i, :take] = dists[:take]
            ind_mat[i, :take] = inds[:take]

    if return_distance:
        return dist_mat, ind_mat
    return ind_mat

kneighbors_graph

kneighbors_graph(
    X: Dataset | Any = None,
    n_neighbors: int | None = None,
    mode: Literal[
        "connectivity", "distance"
    ] = "connectivity",
) -> csr_matrix

Computes the (weighted) sparse graph of K-nearest transmission neighbors.

Parameters:

Name Type Description Default
X Dataset | Any

Optional query Dataset or feature matrix. Defaults to the training data when None.

None
n_neighbors int | None

Optional number of neighbors k per isolate. Defaults to self.n_neighbors.

None
mode Literal['connectivity', 'distance']

Edge weight mode ("connectivity" for binary 1.0 weights or "distance" for genomic distances).

'connectivity'

Returns:

Type Description
csr_matrix

A square SciPy csr_matrix of shape (n_samples, n_samples).

Raises:

Type Description
RuntimeError

If the estimator has not been fitted yet.

ValueError

If mode is not "connectivity" or "distance".

Source code in src/seroepi/estimators/transmission.py
def kneighbors_graph(
    self,
    X: Dataset | Any = None,
    n_neighbors: int | None = None,
    mode: Literal["connectivity", "distance"] = "connectivity",
) -> csr_matrix:
    """Computes the (weighted) sparse graph of K-nearest transmission neighbors.

    Args:
        X: Optional query ``Dataset`` or feature matrix. Defaults to the training data when ``None``.
        n_neighbors: Optional number of neighbors ``k`` per isolate. Defaults to ``self.n_neighbors``.
        mode: Edge weight mode (``"connectivity"`` for binary ``1.0`` weights or ``"distance"`` for genomic distances).

    Returns:
        A square SciPy ``csr_matrix`` of shape ``(n_samples, n_samples)``.

    Raises:
        RuntimeError: If the estimator has not been fitted yet.
        ValueError: If ``mode`` is not ``"connectivity"`` or ``"distance"``.
    """
    self.check_is_fitted()
    if mode not in ("connectivity", "distance"):
        raise ValueError(f"Unsupported mode={mode!r}. Expected 'connectivity' or 'distance'.")

    dist_mat, ind_mat = self.kneighbors(X=X, n_neighbors=n_neighbors, return_distance=True)
    n = ind_mat.shape[0]
    row_list: list[np.ndarray] = []
    col_list: list[np.ndarray] = []
    val_list: list[np.ndarray] = []

    for i in range(n):
        valid = ind_mat[i] >= 0
        if np.any(valid):
            cols = ind_mat[i, valid]
            row_list.append(np.full(len(cols), i, dtype=np.intp))
            col_list.append(cols)
            if mode == "connectivity":
                val_list.append(np.ones(len(cols), dtype=np.float64))
            else:
                val_list.append(dist_mat[i, valid])

    if not row_list:
        return csr_matrix((n, n), dtype=np.float64)

    rows = np.concatenate(row_list)
    cols = np.concatenate(col_list)
    vals = np.concatenate(val_list)
    return coo_array((vals, (rows, cols)), shape=(n, n)).tocsr()

fit_predict

fit_predict(X: Dataset | Any, y: Any = None) -> ndarray

Fits the transmission model on X and returns integer cluster labels_.

Parameters:

Name Type Description Default
X Dataset | Any

A Dataset with attached GenomicDistances, or a 2D precomputed/feature matrix.

required
y Any

Ignored; present for scikit-learn compatibility.

None

Returns:

Type Description
ndarray

1D int64 NumPy array of cluster labels for each isolate.

Source code in src/seroepi/estimators/transmission.py
def fit_predict(self, X: Dataset | Any, y: Any = None) -> np.ndarray:
    """Fits the transmission model on ``X`` and returns integer cluster ``labels_``.

    Args:
        X: A ``Dataset`` with attached ``GenomicDistances``, or a 2D precomputed/feature matrix.
        y: Ignored; present for scikit-learn compatibility.

    Returns:
        1D int64 NumPy array of cluster labels for each isolate.
    """
    return self.fit(X, y).labels_

to_dataset

to_dataset(
    dataset: Dataset, name: str = "transmission_cluster"
) -> Dataset

Returns a new Dataset with the fitted transmission cluster labels joined as a TRANSMISSION_CLONE column.

Parameters:

Name Type Description Default
dataset Dataset

Target Dataset to enrich with fitted transmission cluster labels.

required
name str

Column name for the new Taxonomy(resolution=TRANSMISSION_CLONE) variable.

'transmission_cluster'

Returns:

Type Description
Dataset

A new Dataset containing the joined name column and registered Taxonomy variable.

Raises:

Type Description
RuntimeError

If the estimator has not been fitted yet.

Source code in src/seroepi/estimators/transmission.py
def to_dataset(
    self,
    dataset: Dataset,
    name: str = "transmission_cluster",
) -> Dataset:
    """Returns a new ``Dataset`` with the fitted transmission cluster labels joined as a ``TRANSMISSION_CLONE`` column.

    Args:
        dataset: Target ``Dataset`` to enrich with fitted transmission cluster labels.
        name: Column name for the new ``Taxonomy(resolution=TRANSMISSION_CLONE)`` variable.

    Returns:
        A new ``Dataset`` containing the joined ``name`` column and registered ``Taxonomy`` variable.

    Raises:
        RuntimeError: If the estimator has not been fitted yet.
    """
    self.check_is_fitted()
    id_col = dataset.identifier.column
    cluster_df = self.isolates_.select(
        [
            pl.col(self.id_col_).alias(id_col),
            pl.col("cluster").cast(pl.Categorical).alias(name),
        ]
    )
    lf = dataset.data.lazy()
    if name in dataset.data.columns:
        lf = lf.drop(name)
    new_data = lf.join(cluster_df.lazy(), on=id_col, how="left").collect()

    new_vars = dict(dataset.variables)
    new_vars[name] = Taxonomy(
        column=name,
        title=name,
        resolution=Taxonomy.Resolution.TRANSMISSION_CLONE,
        dtype=pl.Categorical(),
    )
    return Dataset(
        data=new_data,
        name=dataset.name,
        variables=new_vars,
        distances=dataset.distances,
    )

parse_temporal_cutoff_days

parse_temporal_cutoff_days(
    cutoff: str | float | timedelta,
) -> float

Converts a temporal cutoff specification into a non-negative float number of days.

Supports numeric days (int / float), datetime.timedelta, and Polars-style duration strings such as '14d', '4w', '3mo', '1q', '1y', or '48h'.

Parameters:

Name Type Description Default
cutoff str | float | timedelta

Temporal cutoff as numeric days, timedelta, or a duration string.

required

Returns:

Type Description
float

Equivalent non-negative duration in days as a float.

Raises:

Type Description
ValueError

If cutoff is negative, empty, or contains an unrecognized duration unit.

TypeError

If cutoff is not a str, float, int, or timedelta.

Source code in src/seroepi/estimators/transmission.py
def parse_temporal_cutoff_days(cutoff: str | float | timedelta) -> float:
    """Converts a temporal cutoff specification into a non-negative float number of days.

    Supports numeric days (``int`` / ``float``), ``datetime.timedelta``, and Polars-style
    duration strings such as ``'14d'``, ``'4w'``, ``'3mo'``, ``'1q'``, ``'1y'``, or ``'48h'``.

    Args:
        cutoff: Temporal cutoff as numeric days, ``timedelta``, or a duration string.

    Returns:
        Equivalent non-negative duration in days as a float.

    Raises:
        ValueError: If ``cutoff`` is negative, empty, or contains an unrecognized duration unit.
        TypeError: If ``cutoff`` is not a ``str``, ``float``, ``int``, or ``timedelta``.
    """
    if isinstance(cutoff, timedelta):
        days = cutoff.total_seconds() / 86400.0
        if days < 0:
            raise ValueError(f"temporal_cutoff must be non-negative, got {cutoff!r}.")
        return float(days)

    if isinstance(cutoff, (int, float)) and not isinstance(cutoff, bool):
        if float(cutoff) < 0:
            raise ValueError(f"temporal_cutoff must be non-negative, got {cutoff!r}.")
        return float(cutoff)

    if isinstance(cutoff, str):
        raw = cutoff.strip().lower()
        if not raw:
            raise ValueError("temporal_cutoff string cannot be empty.")
        try:
            val = float(raw)
            if val < 0:
                raise ValueError(f"temporal_cutoff must be non-negative, got {cutoff!r}.")
            return val
        except ValueError:
            pass

        tokens = re.findall(r"([+-]?\d+(?:\.\d+)?)\s*([a-z]+)", raw)
        if not tokens or "".join(f"{v}{u}" for v, u in tokens) != re.sub(r"\s+", "", raw):
            raise ValueError(
                f"Invalid temporal_cutoff string {cutoff!r}. Expected format like '14d', '4w', '3mo', '1y', or '48h'."
            )
        total_days = 0.0
        for val_str, unit in tokens:
            val = float(val_str)
            if val < 0:
                raise ValueError(f"temporal_cutoff must be non-negative, got {cutoff!r}.")
            if unit not in _DURATION_UNIT_DAYS:
                raise ValueError(
                    f"Unsupported duration unit {unit!r} in temporal_cutoff {cutoff!r}. "
                    f"Supported units: {sorted(_DURATION_UNIT_DAYS.keys())}."
                )
            total_days += val * _DURATION_UNIT_DAYS[unit]
        return total_days

    raise TypeError(f"temporal_cutoff must be a str, float, int, or timedelta, got {type(cutoff).__name__}.")

haversine_distance_km

haversine_distance_km(
    lat1: ndarray,
    lon1: ndarray,
    lat2: ndarray,
    lon2: ndarray,
) -> ndarray

Computes vectorized great-circle Haversine distance in kilometers between coordinate arrays.

Parameters:

Name Type Description Default
lat1 ndarray

Array of source latitudes in decimal degrees.

required
lon1 ndarray

Array of source longitudes in decimal degrees.

required
lat2 ndarray

Array of target latitudes in decimal degrees.

required
lon2 ndarray

Array of target longitudes in decimal degrees.

required

Returns:

Type Description
ndarray

NumPy array of great-circle distances in kilometers (km).

Source code in src/seroepi/estimators/transmission.py
def haversine_distance_km(
    lat1: np.ndarray,
    lon1: np.ndarray,
    lat2: np.ndarray,
    lon2: np.ndarray,
) -> np.ndarray:
    """Computes vectorized great-circle Haversine distance in kilometers between coordinate arrays.

    Args:
        lat1: Array of source latitudes in decimal degrees.
        lon1: Array of source longitudes in decimal degrees.
        lat2: Array of target latitudes in decimal degrees.
        lon2: Array of target longitudes in decimal degrees.

    Returns:
        NumPy array of great-circle distances in kilometers (km).
    """
    phi1 = np.radians(lat1)
    phi2 = np.radians(lat2)
    dphi = np.radians(lat2 - lat1)
    dlambda = np.radians(lon2 - lon1)

    a = np.sin(dphi / 2.0) ** 2 + np.cos(phi1) * np.cos(phi2) * np.sin(dlambda / 2.0) ** 2
    a = np.clip(a, 0.0, 1.0)
    return 2.0 * _EARTH_RADIUS_KM * np.arcsin(np.sqrt(a))

Comments