"""Contains functions for bootstrap calculations for use in Spark queries."""
import os
from typing import Any, Dict, Callable, List, Optional, Tuple
import logging
import pandas as pd
import numpy as np
from teehr.metrics.models.base import MetricsBasemodel
from teehr.metrics.vectorized_bootstrap_funcs import (
VECTORIZED_BOOTSTRAP_METHODS,
assemble_bootstrap_output,
compute_vectorized_shared_bootstrap,
is_vectorized_metric,
)
from teehr.querying.utils import bootstrap_quantile_key, parse_fields_to_list
logger = logging.getLogger(__name__)
# Engine selector for the shared-bootstrap path. The vectorized engine is now
# the default; set TEEHR_BOOTSTRAP_ENGINE=legacy to fall back to the per-rep
# loop. The escape hatch exists because the two are meant to be numerically
# identical -- if they ever are not, switching back should be one env var, not
# a release.
#
# Read dynamically (not cached at import time) so it can be toggled at runtime
# (e.g. before creating a Spark session) and in tests via monkeypatch. On a
# Spark cluster it must be set on the EXECUTORS, not just the driver --
# spark.executorEnv.TEEHR_BOOTSTRAP_ENGINE -- since the check runs inside the
# pandas UDF.
def _vectorized_engine_enabled() -> bool:
engine = os.environ.get("TEEHR_BOOTSTRAP_ENGINE", "vectorized")
engine = engine.strip().lower()
if engine not in ("vectorized", "legacy"):
# Don't silently pick an engine for a typo. Which way a misspelling
# falls is invisible in the results -- the two engines agree
# numerically -- so the only symptom would be an unexplained 8x
# slowdown, or none at all.
logger.warning(
"Unrecognized TEEHR_BOOTSTRAP_ENGINE=%r; expected 'vectorized' "
"or 'legacy'. Using the default (vectorized).",
engine,
)
return True
return engine == "vectorized"
def _vectorized_engine_available(ref_boot, metrics) -> bool:
"""Group-level preconditions for the vectorized path.
These are properties of the bootstrap object and the group's input fields,
so they genuinely cannot be decided per metric:
- the resampler must expose arch's ``update_indices()`` contract. Gumboot
takes a ``rep`` argument and returns ragged per-water-year index blocks,
so no ``(reps, n)`` matrix exists for it at all.
- ``include_value_time`` adds a third positional arg that the kernels have
no slot for, and ``np.asarray(..., dtype=float)`` on datetimes is lossy.
- the field count must match the kernel contract: ``args[0]`` primary and
an optional ``args[1]`` secondary. This one is not redundant with the
registry -- e.g. ``Signatures.Average(secondary_field_name=...)`` yields
a two-field group whose scalar closure takes one argument, where legacy
raises TypeError but a kernel would quietly ignore ``s_mat`` and
succeed. Rejecting keeps the two paths' error behavior identical.
"""
if type(ref_boot).__name__ not in VECTORIZED_BOOTSTRAP_METHODS:
return False
if ref_boot.include_value_time:
return False
ref = metrics[0]
if hasattr(ref, "get_input_field_names"):
n_fields = len(parse_fields_to_list(ref.get_input_field_names()))
else:
n_fields = len(parse_fields_to_list(ref.input_field_names))
expects_secondary = getattr(ref, "secondary_field_name", None) is not None
return n_fields == (2 if expects_secondary else 1)
def _can_use_vectorized_engine(ref_boot, metrics: List[MetricsBasemodel]) -> bool:
"""Whether the vectorized path can help this bootstrap/metric group.
``any``, not ``all``: metrics without a kernel are evaluated by their own
scalar closure inside ``compute_vectorized_shared_bootstrap``, on the same
draws as the kernels. Requiring every metric to be covered meant one
uncovered metric cost the whole group an ~8x slowdown, and
``bootstrap_group_key`` excludes metric class precisely so that unrelated
metrics *do* share a group.
A group with no covered metrics returns False and takes the untouched
``bs.apply`` path, so nothing changes for it.
"""
if not _vectorized_engine_enabled():
return False
if not _vectorized_engine_available(ref_boot, metrics):
return False
return any(is_vectorized_metric(m) for m in metrics)
def _optimal_block_size(data: np.ndarray, method: str = "stationary") -> int:
"""Estimate the optimal block size for block bootstrap methods.
Implements robust estimation with data normalization, matching the approach
from nwm-explorer to improve numerical stability across diverse data ranges.
Parameters
----------
data:
1-D array of the primary metric input (e.g. primary values).
method:
``"stationary"`` uses the ``b_sb`` column from
``arch.bootstrap.optimal_block_length``;
``"circular"`` uses the ``b_cb`` column.
Returns
-------
int
Estimated block size (at least 2).
"""
from arch.bootstrap import optimal_block_length
clean = np.asarray(data, dtype=float).ravel()
clean = clean[np.isfinite(clean)]
# arch.bootstrap.optimal_block_length can fail on very short or invalid
# arrays (e.g., length <= 2 after dropping NaN/inf).
if clean.size <= 2:
logger.warning(
"Insufficient finite samples (%s) for optimal block estimation; "
"falling back to block_size=2",
int(clean.size),
)
return 2
# Normalize data to stable range [0, 1] before estimation.
# This improves robustness across diverse data magnitudes.
max_value = np.max(np.abs(clean))
if max_value > 0:
normalized = clean / (max_value * 1.01)
else:
normalized = clean
try:
result = optimal_block_length(normalized)
except Exception as exc:
logger.warning(
"optimal_block_length failed (%s: %s); falling back to block_size=2",
type(exc).__name__,
str(exc),
)
return 2
col_candidates = (
("b_sb", "stationary") if method == "stationary"
else ("b_cb", "circular")
)
for col in col_candidates:
if col in result.columns:
value = result[col].iloc[0]
if value is None or not np.isfinite(value):
continue
block_size = int(np.ceil(float(value)))
return max(block_size, 2)
# Fallback for unexpected arch return schema.
logger.warning(
"optimal_block_length columns %s did not include expected %s; "
"falling back to block_size=2",
list(result.columns),
list(col_candidates),
)
return 2
# ---------------------------------------------------------------------------
# Shared-bootstrap helpers
# ---------------------------------------------------------------------------
[docs]
def bootstrap_group_key(metric: MetricsBasemodel) -> Optional[tuple]:
"""Return a hashable key that identifies identical bootstrap configs.
Two metrics with the same key can share a single set of bootstrap samples.
Returns ``None`` for metrics without a bootstrap configuration.
"""
boot = getattr(metric, "bootstrap", None)
if boot is None:
return None
# The input fields the UDF will receive must also match.
if hasattr(metric, "get_input_field_names"):
fields = tuple(metric.get_input_field_names())
else:
fields = tuple(metric.input_field_names or [])
if boot.include_value_time and "value_time" not in fields:
fields = fields + ("value_time",)
# Mirror format.py: the sort fields are appended to the UDF's arg list
# even when already present, so `fields` stays equal to what the UDF is
# actually called with.
sort_fields = tuple(parse_fields_to_list(boot.sort_by)) if boot.sort_by else ()
fields = fields + sort_fields
# Build key from every config field that affects which samples are drawn.
boot_cls = type(boot).__name__
quantile_mode = "quantile" if boot.quantiles is not None else "raw"
quantile_key = tuple(sorted(boot.quantiles)) if boot.quantiles is not None else ()
base = (
boot_cls,
boot.reps,
boot.seed,
quantile_mode,
quantile_key,
boot.include_value_time,
sort_fields,
fields,
# The guards must be part of the key. create_shared_bootstrap_func
# reads them from metrics[0].bootstrap, so two configs differing only
# in a guard would otherwise share a group and the first metric's
# thresholds would silently apply to the rest.
boot.minimum_sample_size,
boot.minimum_mean,
boot.minimum_variance,
)
# Method-specific extra fields
if boot_cls in ("CircularBlock", "Stationary"):
extra = (getattr(boot, "block_size", None),)
elif boot_cls == "Gumboot":
extra = (
getattr(boot, "water_year_month", None),
str(getattr(boot, "boot_year_file", None)),
)
else:
extra = ()
return base + extra
[docs]
def partition_metrics_by_bootstrap(
metrics: List[MetricsBasemodel],
) -> Tuple[List[MetricsBasemodel], Dict[tuple, List[MetricsBasemodel]]]:
"""Split metrics into non-bootstrap and bootstrap-sharing groups.
Returns
-------
no_boot : list
Metrics without a bootstrap config.
boot_groups : dict
Mapping of group key → list of metrics that can share samples.
Singleton groups (len==1) are included so callers can treat all
bootstrap metrics uniformly.
"""
no_boot: List[MetricsBasemodel] = []
boot_groups: Dict[tuple, List[MetricsBasemodel]] = {}
for metric in metrics:
key = bootstrap_group_key(metric)
if key is None:
no_boot.append(metric)
else:
boot_groups.setdefault(key, []).append(metric)
return no_boot, boot_groups
def _make_bs_object(boot, args):
"""Instantiate the correct bootstrap object for a given config."""
boot_cls = type(boot).__name__
if boot_cls == "CircularBlock":
from arch.bootstrap import CircularBlockBootstrap
block_size = boot.block_size
if block_size is None:
block_size = _optimal_block_size(
np.asarray(args[0], dtype=float), method="circular"
)
logger.debug(f"CircularBlock: auto block_size={block_size}")
return CircularBlockBootstrap(
block_size,
*args,
seed=boot.seed,
random_state=boot.random_state,
)
elif boot_cls == "Stationary":
from arch.bootstrap import StationaryBootstrap
block_size = boot.block_size
if block_size is None:
block_size = _optimal_block_size(
np.asarray(args[0], dtype=float), method="stationary"
)
logger.debug(f"Stationary: auto block_size={block_size}")
return StationaryBootstrap(
block_size,
*args,
seed=boot.seed,
random_state=boot.random_state,
)
elif boot_cls == "Gumboot":
from teehr.metrics.gumboot_bootstrap import GumbootBootstrap
vt = args[-1]
metric_args = args[:-1]
return GumbootBootstrap(
*metric_args,
value_time=vt,
seed=boot.seed,
water_year_month=boot.water_year_month,
boot_year_file=boot.boot_year_file,
)
else:
raise ValueError(f"Unsupported bootstrap class: {boot_cls}")
def _sort_args(args: tuple, n_sort_fields: int) -> tuple:
"""Order the group by its trailing sort key(s), dropping them from args.
format.py appends the ``sort_by`` field(s) as the last positional args (see
the comment there), so they can be peeled off here without disturbing the
positions everything else relies on.
The sort is stable, so rows tied on the key keep their arrival order --
still arbitrary, which is why ``sort_by`` accepts several fields. Null or
NaT keys sort last.
"""
keys = args[-n_sort_fields:]
metric_args = args[:-n_sort_fields]
key_frame = pd.DataFrame(
{f"_k{i}": pd.Series(np.asarray(k)) for i, k in enumerate(keys)}
)
order = key_frame.sort_values(
list(key_frame.columns), kind="stable", na_position="last"
).index.to_numpy()
ordered = []
for arg in metric_args:
if isinstance(arg, pd.Series):
ordered.append(arg.iloc[order].reset_index(drop=True))
else:
ordered.append(np.asarray(arg)[order])
return tuple(ordered)
[docs]
def create_shared_bootstrap_func(
metrics: List[MetricsBasemodel],
) -> Callable:
"""Create a single bootstrap UDF that evaluates multiple metrics per draw.
All metrics in *metrics* must share the same bootstrap configuration
(same class, reps, seed, block_size, quantiles, guards, and input fields).
The caller must pass the bootstrap's ``sort_by`` field(s) as the last
positional args of the UDF, which is what ``format.py`` does. How many
there are is read off the same ``sort_by`` it read to build the arg list,
rather than being handed over separately -- one fact, one place.
Parameters
----------
metrics : List[MetricsBasemodel]
Metrics sharing the same bootstrap config.
Returns
-------
Callable
UDF returning dict with per-metric quantiles or raw bootstrap arrays.
"""
# Reference bootstrap config from the first metric (all are equivalent).
ref_boot = metrics[0].bootstrap
# Quality guards come from the bootstrap config rather than this call:
# they describe the resampling, and bootstrap_group_key already keys
# groups on the config, so metrics in a group necessarily share them.
minimum_sample_size = ref_boot.minimum_sample_size
minimum_mean = ref_boot.minimum_mean
minimum_variance = ref_boot.minimum_variance
# The trailing args holding the sort key(s), which the group is ordered
# by and which no metric ever sees.
n_sort_fields = (
len(parse_fields_to_list(ref_boot.sort_by)) if ref_boot.sort_by else 0
)
# Build per-metric inner functions once at UDF-creation time.
metric_funcs = [m.func(m) for m in metrics]
quantiles = ref_boot.quantiles
output_names = [m.output_field_name for m in metrics]
def shared_bootstrap_func(*args: pd.Series) -> Dict[str, Any]:
# Order the group BEFORE anything else looks at args: the guards below
# read args[0], _make_bs_object reads args[0] for the auto block size
# and args[-1] for Gumboot's value_time, and the resamplers draw blocks
# of adjacent rows. Everything downstream therefore sees the sorted
# group and needs no changes.
if n_sort_fields:
args = _sort_args(args, n_sort_fields)
# Validate series quality before attempting bootstrap (nwm-explorer pattern).
primary_series = np.asarray(args[0], dtype=float)
if len(primary_series) < minimum_sample_size:
logger.debug(
"Sample size %s < minimum %s; skipping bootstrap.",
len(primary_series),
minimum_sample_size,
)
return {name: None for name in output_names}
mean_val = np.nanmean(primary_series)
if mean_val < minimum_mean:
logger.debug(
"Mean %.6e < minimum %.6e; skipping bootstrap.",
mean_val,
minimum_mean,
)
return {name: None for name in output_names}
var_val = np.nanvar(primary_series)
if var_val < minimum_variance:
logger.debug(
"Variance %.6e < minimum %.6e; skipping bootstrap.",
var_val,
minimum_variance,
)
return {name: None for name in output_names}
bs = _make_bs_object(ref_boot, args)
if _can_use_vectorized_engine(ref_boot, metrics):
return compute_vectorized_shared_bootstrap(
metrics, metric_funcs, args, bs, ref_boot.reps, quantiles
)
# Each draw: evaluate ALL metric functions and return a list.
def combined_func(*draw_args):
# arch.bootstrap.apply expects a scalar or NumPy array output.
# Returning a Python list/tuple can trigger shape inference issues.
return np.asarray([fn(*draw_args) for fn in metric_funcs], dtype=float)
# results shape: (reps, N_metrics)
results = bs.apply(combined_func, ref_boot.reps)
return assemble_bootstrap_output(output_names, results, quantiles)
return shared_bootstrap_func
def _calculate_quantiles(
output_field_name: str,
results: np.ndarray,
quantiles: list
) -> Dict:
"""Calculate quantile values of the bootstrap results."""
values = np.quantile(results, quantiles)
quantiles = [bootstrap_quantile_key(output_field_name, i) for i in quantiles]
d = dict(zip(quantiles, values))
return d
[docs]
def create_circularblock_func(model: MetricsBasemodel) -> Callable:
"""Create the CircularBlock bootstrap function.
If ``model.bootstrap.block_size`` is ``None``, the block size is estimated
using ``arch.bootstrap.optimal_block_length`` (``b_cb`` column).
Not used by the aggregation pipeline. ``format.py`` routes every bootstrap
group -- including groups of one -- through
``create_shared_bootstrap_func``, so this is retained as the public
``Bootstrappers.*.func`` field default (frozen, see
``models/bootstrap.py``), for the autodoc page, and as an independent
reference implementation the equivalence tests compare against. It has no
sample-size/mean/variance quality guards, unlike the shared path.
"""
logger.debug("Building the Circular Block bootstrap func.")
# lazy import to improve performance
from arch.bootstrap import CircularBlockBootstrap
def bootstrap_func(*args: pd.Series) -> Dict:
"""Bootstrap function."""
block_size = model.bootstrap.block_size
if block_size is None:
block_size = _optimal_block_size(
np.asarray(args[0], dtype=float), method="circular"
)
logger.debug(
f"CircularBlock: auto block_size={block_size}"
)
bs = CircularBlockBootstrap(
block_size,
*args,
seed=model.bootstrap.seed,
random_state=model.bootstrap.random_state
)
results = bs.apply(
model.func(model),
model.bootstrap.reps
)
if model.bootstrap.quantiles is not None:
return _calculate_quantiles(
model.output_field_name,
results,
model.bootstrap.quantiles,
)
else:
return results.ravel()
return bootstrap_func
[docs]
def create_gumboot_func(model: MetricsBasemodel) -> Callable:
"""Create the Gumboot bootstrap function.
Not used by the aggregation pipeline. ``format.py`` routes every bootstrap
group -- including groups of one -- through
``create_shared_bootstrap_func``, so this is retained as the public
``Bootstrappers.*.func`` field default (frozen, see
``models/bootstrap.py``), for the autodoc page, and as an independent
reference implementation the equivalence tests compare against. It has no
sample-size/mean/variance quality guards, unlike the shared path.
"""
logger.debug("Building the Gumboot bootstrap func.")
# lazy import to improve performance
from teehr.metrics.gumboot_bootstrap import GumbootBootstrap
def bootstrap_func(*args: pd.Series) -> Dict:
"""Bootstrap function."""
# value_time is always appended last when required by bootstrap config.
vt = args[-1]
metric_args = args[:-1]
bs = GumbootBootstrap(
*metric_args,
value_time=vt,
seed=model.bootstrap.seed,
water_year_month=model.bootstrap.water_year_month,
boot_year_file=model.bootstrap.boot_year_file
)
results = bs.apply(
model.func(model),
model.bootstrap.reps
)
if model.bootstrap.quantiles is not None:
return _calculate_quantiles(
model.output_field_name,
results,
model.bootstrap.quantiles,
)
else:
return results.ravel()
return bootstrap_func
[docs]
def create_stationary_func(model: MetricsBasemodel) -> Callable:
"""Create the Stationary bootstrap function.
If ``model.bootstrap.block_size`` is ``None``, the block size is estimated
using ``arch.bootstrap.optimal_block_length`` (``b_sb`` column).
Not used by the aggregation pipeline. ``format.py`` routes every bootstrap
group -- including groups of one -- through
``create_shared_bootstrap_func``, so this is retained as the public
``Bootstrappers.*.func`` field default (frozen, see
``models/bootstrap.py``), for the autodoc page, and as an independent
reference implementation the equivalence tests compare against. It has no
sample-size/mean/variance quality guards, unlike the shared path.
"""
logger.debug("Building the Stationary bootstrap func.")
# lazy import to improve performance
from arch.bootstrap import StationaryBootstrap
def bootstrap_func(*args: pd.Series) -> Dict:
"""Bootstrap function."""
block_size = model.bootstrap.block_size
if block_size is None:
block_size = _optimal_block_size(
np.asarray(args[0], dtype=float), method="stationary"
)
logger.debug(
f"Stationary: auto block_size={block_size}"
)
bs = StationaryBootstrap(
block_size,
*args,
seed=model.bootstrap.seed,
random_state=model.bootstrap.random_state
)
results = bs.apply(
model.func(model),
model.bootstrap.reps
)
if model.bootstrap.quantiles is not None:
return _calculate_quantiles(
model.output_field_name,
results,
model.bootstrap.quantiles,
)
else:
return results.ravel()
return bootstrap_func