Ensemble Analysis

This tutorial analyses multiple simulation runs together with the Ensemble object, using the GX members in examples/data/gx/ensemble.

Instead of one long trace, an ensemble combines several shorter runs. QUENDS provides three ways to turn an ensemble into a mean with an honest uncertainty:

  • Ensemble Average – average the members onto a common grid, then analyse that single averaged trace,

  • Serialization (pooled block means) – pool the per-member block means,

  • Inverse-Variance-Weighted (IVW) – weight each member’s mean by its inverse variance.

For single-trace analysis, see the DataStream Class guide. Trim/statistics parameters follow the QUENDS paper analysis notebooks for the stellarator GX ensemble (method="threshold", window_size=50, start_time=100, threshold=0.19).

Import QUENDS

import glob
from pathlib import Path

import quends as qnds


def example_data_dir() -> Path:
    """Find the shared example data directory during script or gallery runs."""
    starts = []
    if "__file__" in globals():
        starts.append(Path(__file__).resolve())
    starts.append(Path.cwd().resolve())

    for start in starts:
        for parent in [start, *start.parents]:
            for candidate in (parent / "examples" / "data", parent / "data"):
                if candidate.is_dir():
                    return candidate
    raise FileNotFoundError("Could not locate examples/data")


COL = "HeatFlux_st"
DATA_DIR = example_data_dir()
plotter = qnds.Plotter()

Data Loading

Ensemble.from_files loads each CSV into a member DataStream.

gx_files = sorted(glob.glob(str(DATA_DIR / "gx" / "ensemble" / "tprim_2_5_*.out.csv")))
ens = qnds.Ensemble.from_files(gx_files, COL)
print("ensemble members:", len(ens.members()))
ensemble members: 4

Plotting all members together with the ensemble average.

plot = plotter.plot_ensemble_with_average(
    ens, variables_to_plot=[COL], condensed_legend=True, show=True
)
HeatFlux_st

Ensemble Average Approach

Average the members onto a common time grid, then analyse that single averaged trace exactly like a single run.

Build the averaged DataStream and check stationarity.

avg = ens.compute_average_ensemble()
print("averaged trace rows:", len(avg))
print("avg is_stationary:", avg.is_stationary(COL))
averaged trace rows: 740
avg is_stationary: {'HeatFlux_st': True}

Trim the averaged trace, then read off its ESS.

avg_trimmed = avg.trim(
    method="threshold", window_size=50, start_time=100, threshold=0.19
)
print("avg sss_start:", avg_trimmed.trim_metadata.get("sss_start"))
print("avg ESS:", avg_trimmed.effective_sample_size())
avg sss_start: 126.838088
/home/runner/work/quends/quends/src/quends/base/utils.py:300: UserWarning: The computed signal decorrelation time is large compared to the max lag in the computation of the autocorrelation. Results may be inaccurate. Estimated tau_int=116.19, nlags=126.
  warnings.warn(
avg ESS: {'results': {'HeatFlux_st': 5}}

The Ensemble Average estimate of the mean and its uncertainty. We analyse the trimmed averaged trace (average -> trim -> statistics), so the transient is removed before the mean and its standard error are computed. NB: the ensemble_average estimator does not trim internally – running it on a raw, un-trimmed average folds the transient into the variance and greatly inflates the uncertainty.

ea_stats = avg_trimmed.compute_statistics(method="non-overlapping")[COL]
print("Ensemble Average ->", ea_stats)
Ensemble Average -> {'mean': 7.94494859258104, 'mean_uncertainty': 0.12784770311653157, 'variance': 0.14710531672955518, 'confidence_interval': (7.694367094472638, 8.195530090689441), 'standard_deviation': 0.38354310934959474, 'pm_std': (7.817100889464509, 8.072796295697572), 'effective_sample_size': 5, 'window_size': 51, 'n_short_averages': 9, 'ess_blocks': 4.868431876206393, 'se_effective_n': 9.0, 'se_method': 'iid_blocks', 'independence_status': 'independent', 'independent': True, 'ljungbox_lags': [5, 8], 'ljungbox_pvalues': [0.628211818690967, 0.09060661184104132], 'ljungbox_pvalue': 0.09060661184104132, 'ci_method': 'normal', 'confidence_level': 0.95, 'metadata': {'warnings': ['The computed signal decorrelation time is large compared to the max lag in the computation of the autocorrelation. Results may be inaccurate. Estimated tau_int=116.19, nlags=126.']}}

Serialization (Pooled Block Means) Approach

Rather than averaging the traces, pool the per-member block means. Trim every member first, then aggregate across members.

ens_trimmed = ens.trim(
    column_name=COL, method="threshold", window_size=50, start_time=100, threshold=0.19
)
print("members stationary:", ens.is_stationary(COL)["results"])
print("ESS (pooled_block_means):", ens_trimmed.effective_sample_size(COL)["results"])
members stationary: {'Member 0': {'HeatFlux_st': True}, 'Member 1': {'HeatFlux_st': True}, 'Member 2': {'HeatFlux_st': True}, 'Member 3': {'HeatFlux_st': True}}
/home/runner/work/quends/quends/src/quends/base/utils.py:300: UserWarning: The computed signal decorrelation time is large compared to the max lag in the computation of the autocorrelation. Results may be inaccurate. Estimated tau_int=20.34, nlags=25.
  warnings.warn(
/home/runner/work/quends/quends/src/quends/base/utils.py:300: UserWarning: The computed signal decorrelation time is large compared to the max lag in the computation of the autocorrelation. Results may be inaccurate. Estimated tau_int=28.08, nlags=25.
  warnings.warn(
/home/runner/work/quends/quends/src/quends/base/utils.py:300: UserWarning: The computed signal decorrelation time is large compared to the max lag in the computation of the autocorrelation. Results may be inaccurate. Estimated tau_int=24.56, nlags=18.
  warnings.warn(
/home/runner/work/quends/quends/src/quends/base/utils.py:300: UserWarning: The computed signal decorrelation time is large compared to the max lag in the computation of the autocorrelation. Results may be inaccurate. Estimated tau_int=15.30, nlags=24.
  warnings.warn(
ESS (pooled_block_means): {'HeatFlux_st': 13.164635042137682}

The serialization estimate of the mean and its uncertainty, on the trimmed members.

ser_stats = ens_trimmed.compute_uncertainty(
    method="pooled_block_means", column_name=COL
)["results"][COL]
print("Serialization ->", ser_stats)
Serialization -> {'mean': 7.796421158767362, 'variance': 0.3107674364166239, 'ess_blocks': 13.164635042137682, 'n_short_averages': 32, 'mean_uncertainty': 0.15364319888420833, 'mean_uncertainty_sem_n': 0.09854685377027263, 'mean_uncertainty_sem_ess': 0.15364319888420833, 'se_method': 'sem_ess (members_not_all_independent)', 'warning': 'Some members used best_p window. Not all members passed independence; using ESS-based SEM.', 'confidence_interval': (7.4952804889543145, 8.09756182858041), 'pm_std': (7.642777959883154, 7.950064357651571), 'window_size': 12, 'member_window_sizes': [12, 12, 9, 12], 'window_size_summary': 12, 'window_size_summary_method': 'median', 'window_size_min': 9, 'window_size_max': 12, 'independent': False, 'independence_status': 'some_independent', 'ljungbox_pvalue': 0.0021946248310642496, 'ljungbox_pvalues': [0.05617425200409796, 0.004476994141746214, 0.0021946248310642496, 0.13347036331480538], 'ljungbox_lags': [5, 5, 5, 5], 'ci_method': 'normal', 'confidence_level': 0.95, 'member_all_independent': False, 'member_some_best_p': True}

Inverse-Variance-Weighted (IVW) Approach

Combine the per-member means weighting each by its inverse variance, so better-resolved members count more. Trimming/stationarity are as above.

print("ESS (ivw):", ens_trimmed.effective_sample_size(COL, technique="ivw")["results"])
ivw_stats = ens_trimmed.compute_uncertainty(method="ivw", column_name=COL)["results"][
    COL
]
print("IVW ->", ivw_stats)
ESS (ivw): {'HeatFlux_st': 20.5090946620309}
IVW -> {'mean': 7.783719757167311, 'mean_uncertainty': 0.07425546570409433, 'confidence_interval': (7.638179044387286, 7.9292604699473355), 'pm_std': (7.709464291463217, 7.857975222871405), 'variance': 0.309226765823394, 'ess_blocks': 20.5090946620309, 'n_short_averages': 34.0, 'se_method': 'ivw_member_means', 'warning': None, 'window_size': 12, 'member_window_sizes': [10, 17, 13, 7], 'window_size_summary': 12, 'window_size_summary_method': 'median', 'window_size_min': 7, 'window_size_max': 17, 'independence_status': 'all_independent', 'independent': True, 'ljungbox_pvalue': 0.06735850513592934, 'ljungbox_pvalues': [0.05675964391256825, 0.05366281164550079, 0.08459568298330894, 0.07441588200233934], 'ljungbox_lags': [5, 9], 'ci_method': 'normal', 'confidence_level': 0.95, 'individual': None}

Computed on the trimmed steady-state data, the three approaches give consistent means with comparable uncertainties – a useful cross-check on an ensemble.

for name, r in [
    ("Ensemble Average", ea_stats),
    ("Serialization", ser_stats),
    ("IVW", ivw_stats),
]:
    print(
        f"{name:18s} mean={r.get('mean'):.4f}  uncertainty={r.get('uncertainty', r.get('mean_uncertainty')):.4f}"
    )
Ensemble Average   mean=7.9449  uncertainty=0.1278
Serialization      mean=7.7964  uncertainty=0.1536
IVW                mean=7.7837  uncertainty=0.0743

Total running time of the script: (0 minutes 0.335 seconds)

Gallery generated by Sphinx-Gallery


Last update: Aug 11, 2026