SimulationStudy Class

A streamlined guide using the SimulationStudy manager.

Use the SimulationStudy manager to handle the entire lifecycle: storage, diagnostics, active refinement, analysis, and intelligent caching.

Note

In this tutorial, we will intentionally feed the study “bad” data (with a large gap in the input space) to demonstrate how the active learning module automatically identifies and fixes issues. We will also demonstrate how the new Layered Caching engine makes exploring multi-dimensional parameter spaces nearly instantaneous.

1. Setup, Generation & Smart Initialization

We’ll define a simulated physics function with heteroscedastic noise and generate an initial design containing a deliberate gap between 3mm and 7mm.

Notice that we no longer need to explicitly define our input columns when creating the SimulationStudy; the manager will automatically infer them from the data!

import numpy as np
import pandas as pd
import time
from digiqual import SimulationStudy


# --- Define the Physics ---
def apply_physics(df):
    """Simulates a signal with quadratic trends and heteroscedastic noise."""
    signal = (
        10.0
        + (2.0 * df["Length"])
        + (0.5 * df["Length"] ** 2)
        - (0.1 * np.abs(df["Angle"]))
    )
    noise_scale = 0.5 + (1.5 * df["Roughness"])
    noise = np.random.normal(loc=0, scale=noise_scale, size=len(df))
    return signal + noise


# --- Create Flawed Data (Gap between 3mm and 7mm) ---
df1 = pd.DataFrame(
    {
        "Length": np.random.uniform(0.1, 3.0, 40),
        "Angle": np.random.uniform(-10, 10, 40),
        "Roughness": np.random.uniform(0, 0.5, 40),
    }
)
df2 = pd.DataFrame(
    {
        "Length": np.random.uniform(7.0, 10.0, 40),
        "Angle": np.random.uniform(-10, 10, 40),
        "Roughness": np.random.uniform(0, 0.5, 40),
    }
)

df_initial = pd.concat([df1, df2], ignore_index=True)
df_initial["Signal"] = apply_physics(df_initial)

# --- The NEW Smarter Initialization ---
# 1. Initialize with a clean slate
study = SimulationStudy()

# 2. Add data: It automatically infers 'Length', 'Angle', 'Roughness' as inputs!
study.add_data(df_initial, outcome_col="Signal", overwrite=True)

# Test the UI Helper methods
print(f"Inferred Inputs: {study.inputs}")
threshold_defaults = study.get_data_summary("Signal")
print(f"Auto-Threshold suggestion (Median Signal): {threshold_defaults['median']:.2f}")
Data initialized/overwritten. Total rows: 80
Inferred Inputs: ['Length', 'Angle', 'Roughness']
Auto-Threshold suggestion (Median Signal): 34.37
TipNote on Design Generation: generate_lhs

When creating initial space-filling designs programmatically (instead of synthetic random samples), you can use the digiqual.sampling.generate_lhs(n, ranges) utility. The library automatically applies a greedy max-min distance reordering algorithm to the generated coordinates. This ensures that any prefix of size \(k\) is as space-filling as possible, allowing you to stop the physical solver early or execute runs incrementally while maintaining optimal coverage of the parameter space.

2. Diagnosis (Detecting the Issue)

We ask the SimulationStudy manager to diagnose the health of our experiment.

Warning

Because of our deliberate gap, we expect the diagnostic engine to flag the Input Coverage test as failed.

Additionally, the diagnostic suite now automatically checks for multicollinearity by calculating the Variance Inflation Factor (VIF) for each input variable (controlled by the max_allowed_vif parameter, which defaults to 5.0).

# Run the diagnostic tests
study.diagnose()
Running validation...
Validation passed. 80 valid rows ready.
Checking sample sufficiency...
Test Variable Metric Value Threshold Pass
0 Input Coverage Length Max Gap Ratio 0.4109 < 0.20 False
1 Input Coverage Angle Max Gap Ratio 0.0469 < 0.20 True
2 Input Coverage Roughness Max Gap Ratio 0.0618 < 0.20 True
3 Model Fit (CV) Signal Mean R2 Score 0.9977 > 0.50 True
4 Bootstrap Convergence Signal Avg CV (Rel Std Dev) 0.0150 < 0.15 True
5 Bootstrap Convergence Signal Max CV (Rel Std Dev) 0.0299 < 0.30 True
6 Collinearity Check Length VIF 1.0100 < 5.00 True
7 Collinearity Check Angle VIF 1.0013 < 5.00 True
8 Collinearity Check Roughness VIF 1.0098 < 5.00 True

You can also visually inspect collinearity using the plot_collinearity() method, which renders a correlation matrix heatmap with VIF and correlation coefficient annotations.

study.plot_collinearity()
Figure 1: Collinearity analysis matrix

3. Adaptive Refinement (The Fix)

Instead of manually guessing where to add new simulations, we use the refine() method.

  1. Detect: It automatically targets the exact region where the gap or uncertainty exists.
  2. Generate: It outputs a dataframe of new input coordinates.
  3. Execute & Store: Appending the results back using add_data() automatically clears the mathematical caches to ensure your next analysis uses the updated physics!
# Generate active learning samples (20 points to fill the gap)
new_samples = study.refine(n_points=20)

if not new_samples.empty:
    # Apply the exact same physics model to the new points
    new_samples["Signal"] = apply_physics(new_samples)

    # Add back to study (This strips metadata and drops the caches!)
    study.add_data(new_samples)

# Re-run diagnostics to confirm the green light
study.diagnose()
Diagnostics flagged issues. Initiating Active Learning...
 -> Strategy: Exploration (Filling gaps in Length)
Data appended. Total rows: 100
Running validation...
Validation passed. 100 valid rows ready.
Checking sample sufficiency...
Test Variable Metric Value Threshold Pass
0 Input Coverage Length Max Gap Ratio 0.0654 < 0.20 True
1 Input Coverage Angle Max Gap Ratio 0.0469 < 0.20 True
2 Input Coverage Roughness Max Gap Ratio 0.0618 < 0.20 True
3 Model Fit (CV) Signal Mean R2 Score 0.9977 > 0.50 True
4 Bootstrap Convergence Signal Avg CV (Rel Std Dev) 0.0124 < 0.15 True
5 Bootstrap Convergence Signal Max CV (Rel Std Dev) 0.0243 < 0.30 True
6 Collinearity Check Length VIF 1.0054 < 5.00 True
7 Collinearity Check Angle VIF 1.0005 < 5.00 True
8 Collinearity Check Roughness VIF 1.0049 < 5.00 True

4. Reliability Analysis & Layered Caching

With a validated dataset, we can now run the complete PoD pipeline.

Tip

The SimulationStudy engine uses a Layered Caching Architecture.

When you run .pod() for the first time, it evaluates all possible models and stores the mathematical heavy-lifting in memory. If you want to change a parameter slice (e.g., investigating a different ‘Angle’), you can use .update_slice() to instantly generate a new PoD curve without refitting the model!

The single pod() method automatically handles: - Monte-Carlo Integration (marginalising out Nuisance variables using Uniform or custom distributions like Normal, Lognormal, and Weibull) - Sobol Sensitivity Analysis (calculating total-order parameter sensitivity indices) - Model Selection (finding the best polynomial fit) - Distribution Inference (finding the best error distribution) - Bootstrapping (generating confidence bounds)

# --- RUN 1: Baseline Analysis ---
# Expect this to take a moment as it trains models
# and optimizes variance bandwidth.
# We configure 'Roughness' with a custom Normal distribution: mean=0.25, std=0.08
t0 = time.time()
results = study.pod(
    poi_col="Length",
    nuisance_col=["Roughness"],  # Integrate over roughness
    slice_values={"Angle": 0.0},  # Hold Angle strictly at 0.0!
    threshold=34.0,
    n_boot=0,  # Skipping bootstrap for the speed demonstration
    nuisance_dists={"Roughness": ("norm", (0.25, 0.08))},
)
print(f"Run 1 completed in: {time.time() - t0:.4f} seconds")

# Access the calculated Sobol sensitivity indices for each input parameter
print("Sobol Sensitivity Indices (Total Effect):")
for var, val in results["sobol_indices"].items():
    print(f"  {var}: {val * 100:.1f}%")

# --- RUN 2: Interactive Slicing ---
# Changing the constant 'Angle' slice triggers
# a Cache Hit for the model and variance!
t1 = time.time()
new_results = study.update_slice({"Angle": 5.0})
print(f"Run 2 completed in: {time.time() - t1:.4f} seconds (Near Instant!)")
print(f"New a90/95 point at Angle 5.0: {new_results['a90_95']:.3f}")
--- Starting Reliability Analysis (PoIs: ['Length'] - Nuisance: ['Roughness']) ---
-> Slicing surface at: {'Angle': 0.0}
1. Training all surrogate models (Cache Miss)...
-> Selected Model: Polynomial (Degree 2)
2. Fitting Variance Model & Inferring Distribution (Cache Miss)...
   -> Optimizing bandwidth via LOO-CV...
-> Calculating Total-Order Sobol Indices...
   -> Smoothing Bandwidth: 9.9668
   -> Selected Distribution: norm
3. Integrating PoD Curve from Scratch (Cache Miss)...
4. Skipping Bootstrap (n_boot=0)...
--- Analysis Complete ---
Run 1 completed in: 0.9392 seconds
Sobol Sensitivity Indices (Total Effect):
  Length: 100.0%
  Angle: 0.0%
  Roughness: 0.0%
--- Starting Reliability Analysis (PoIs: ['Length'] - Nuisance: ['Roughness']) ---
-> Slicing surface at: {'Angle': 5.0}
1. Loading surrogate models from cache (Cache Hit)...
-> Selected Model: Polynomial (Degree 2)
2. Loading Variance Model, Distribution & Sobol from cache (Cache Hit)...
   -> Smoothing Bandwidth: 9.9668
   -> Selected Distribution: norm
3. Integrating PoD Curve from Scratch (Cache Miss)...
4. Skipping Bootstrap (n_boot=0)...
--- Analysis Complete ---
Run 2 completed in: 0.2292 seconds (Near Instant!)
New a90/95 point at Angle 5.0: nan
# Visualise the results of the final active slice
study.visualise()
(a) Best Fitting Model (Statistics)
(b) Signal Response Model (Physics)
(c) Probability of Detection Curve (Reliability)
Figure 2: Reliability Analysis Results

5. Uncertainty Quantification & Threshold Sensitivity

To calculate reliability indices with confidence intervals, we run the bootstrapping algorithm.

Unlike traditional tools that compute only a single confidence interval (e.g., 95%), digiqual runs bootstrapping once to extract bounds for multiple confidence levels (50%, 90%, 95%, 99%) across standard target PoDs (50%, 90%, 95%, 99%) simultaneously.

These are saved in the results dictionaries: - results['ci_bounds']: Maps confidence levels to lower/upper curves. - results['reliability_table']: Maps (target_pod_percent, confidence_level) tuples to their corresponding flaw sizing reliability indices (e.g., a90/95).

# Run the parallelised bootstrap to get multi-level confidence bounds
t2 = time.time()
uq_results = study.pod(
    poi_col="Length",
    nuisance_col=["Roughness"],
    slice_values={"Angle": 5.0},
    threshold=34.0,
    n_boot=100,  # Small value for quick demonstration
    n_jobs=-1    # Use all available CPU cores
)
print(f"UQ Bootstrapping completed in: {time.time() - t2:.4f} seconds")

# Access the custom reliability matrix values
# Print the calculated a90/95 and a90/90 values
a90_95 = uq_results["reliability_table"].get((90, 95))
a90_90 = uq_results["reliability_table"].get((90, 90))
print(f"a90/95: {a90_95:.3f} mm")
print(f"a90/90: {a90_90:.3f} mm")
--- Starting Reliability Analysis (PoIs: ['Length'] - Nuisance: ['Roughness']) ---
-> Slicing surface at: {'Angle': 5.0}
1. Loading surrogate models from cache (Cache Hit)...
-> Selected Model: Polynomial (Degree 2)
2. Loading Variance Model, Distribution & Sobol from cache (Cache Hit)...
   -> Smoothing Bandwidth: 9.9668
   -> Selected Distribution: norm
3. Loading PoD Curve from Individual Cache (Layer 3 Hit)...
4. Running Bootstrap (100 iterations on 11 cores)...
[Parallel(n_jobs=11)]: Using backend MultiprocessingBackend with 11 concurrent workers.
[Parallel(n_jobs=11)]: Done  19 tasks      | elapsed:   12.8s
   -> a90/95 Reliability Index: 5.456
--- Analysis Complete ---
UQ Bootstrapping completed in: 13.6812 seconds
a90/95: 5.456 mm
a90/90: 5.451 mm
[Parallel(n_jobs=11)]: Done 100 out of 100 | elapsed:   13.7s finished

We can specify a custom target_pod and confidence_level when calling visualise() to draw and annotate the selected point.

study.visualise(target_pod=0.90, confidence_level=90)
(a) Best Fitting Model (Statistics)
(b) Signal Response Model (Physics)
(c) UQ Reliability S-curve with 90% Confidence Bounds for a 90% target PoD
Figure 3: Reliability Analysis Results

Threshold Sensitivity Plots

Using the cached 100-threshold spectrum, we can also generate a sensitivity plot using plot_pod_vs_threshold() to see how changing the threshold affects PoD across defect sizes.

# Generate threshold spectrum (using Layer 4 caching)
study.compute_pod_spectrum(poi_col="Length")

# Plot sensitivity curves
_ = study.plot_pod_vs_threshold()
--- Initiating Threshold Spectrum Generation ---
--- Starting Reliability Analysis (PoIs: ['Length'] - Nuisance: []) ---
-> Slicing surface at: {'Angle': -0.8274982924692242, 'Roughness': 0.255838971504394}
1. Loading surrogate models from cache (Cache Hit)...
-> Selected Model: Polynomial (Degree 2)
2. Loading Variance Model, Distribution & Sobol from cache (Cache Hit)...
   -> Smoothing Bandwidth: 9.9668
   -> Selected Distribution: norm
3. Integrating PoD Curve from Scratch (Cache Miss)...
4. Skipping Bootstrap (n_boot=0)...
--- Analysis Complete ---
4. Computing PoD Spectrum for 100 thresholds (Layer 4 Cache Miss)...
--- Spectrum Generation Complete ---
Figure 4: PoD vs. Threshold Sensitivity Plot

Appendix: Full Script

import numpy as np
import pandas as pd
import time
from digiqual import SimulationStudy


# --- 1. Define the Physics ---
def apply_physics(df):
    """Simulates a signal with quadratic trends and heteroscedastic noise."""
    signal = (
        10.0
        + (2.0 * df["Length"])
        + (0.5 * df["Length"] ** 2)
        - (0.1 * np.abs(df["Angle"]))
    )
    noise_scale = 0.5 + (1.5 * df["Roughness"])
    noise = np.random.normal(loc=0, scale=noise_scale, size=len(df))
    return signal + noise


# --- 2. Create Flawed Data ---
df1 = pd.DataFrame(
    {
        "Length": np.random.uniform(0.1, 3.0, 40),
        "Angle": np.random.uniform(-10, 10, 40),
        "Roughness": np.random.uniform(0, 0.5, 40),
    }
)
df2 = pd.DataFrame(
    {
        "Length": np.random.uniform(7.0, 10.0, 40),
        "Angle": np.random.uniform(-10, 10, 40),
        "Roughness": np.random.uniform(0, 0.5, 40),
    }
)
df_initial = pd.concat([df1, df2], ignore_index=True)
df_initial["Signal"] = apply_physics(df_initial)

# --- 3. Initialize & Ingest ---
study = SimulationStudy()
study.add_data(df_initial, outcome_col="Signal", overwrite=True)

# --- 4. Diagnose & Refine ---
study.diagnose()
study.plot_collinearity()

new_samples = study.refine(n_points=20)

if not new_samples.empty:
    new_samples["Signal"] = apply_physics(new_samples)
    study.add_data(new_samples)

# --- 5. Fast-Path Reliability Exploration (Caching) ---
# Calculate the base physics model (n_boot=0) to prime Layers 1 and 2
study.pod(poi_col="Length", threshold=15.0, n_boot=0)

# Generate a 100-threshold spectrum matrix (Layer 4 Cache) instantly
spectrum = study.compute_pod_spectrum(poi_col="Length")
print("Spectrum generated for thresholds:", spectrum["thresholds"][:3], "...")

# Plot threshold sensitivity curves
study.plot_pod_vs_threshold()

# --- 6. Uncertainty Quantification (Bootstrapping) ---
# Run the heavy, parallelised bootstrap to get multi-level Confidence Intervals
results = study.pod(poi_col="Length", threshold=15.0, n_boot=100, n_jobs=-1)

# Print specific reliability matrix values (e.g. a90/95)
a90_95 = results["reliability_table"].get((90, 95))
print(f"a90/95: {a90_95:.2f}")

# --- 7. Visualisation ---
# Annotate target PoD of 90% and confidence level of 95%
study.visualise(target_pod=0.90, confidence_level=95)