Polar Uncertainty Diagnostics#
pycsamt.emtools.diag evaluates predicted uncertainty intervals
against observed CSAMT/AMT apparent resistivity. It adapts the
k-diagram polar uncertainty ideas to electromagnetic soundings:
coverage, relative interval width, and relative prediction error.
This module is different from most emtools pages. It cannot work
from observed EDI data alone. You must provide a prediction to evaluate:
lower and upper apparent-resistivity bounds,
q_loandq_hi;optionally, a point prediction
model_rhofor relative-error plots.
Full callable signatures live in the API reference. This user-guide page focuses on the workflow, data shapes, returned tables, plots, and interpretation.
What The Diagnostics Measure#
For each station and frequency, the module computes observed apparent resistivity from one off-diagonal impedance component:
where pq is xy by default and f is frequency in hertz. This
is the same practical-unit EDI convention used by the other
emtools resistivity diagnostics.
Given predicted bounds \([L_j, U_j]\), coverage is:
The empirical coverage of a station is the mean of those binary values.
For a nominal 90 percent interval, a station with empirical coverage
above 0.9 is flagged as calibrated by the default rule.
The module also reports relative interval width:
and relative point-prediction error:
Coverage tells you whether observations fall inside predicted intervals. Width tells you whether that coverage was useful or merely overly cautious. Error tells you where a point prediction systematically over- or under-predicts the observed sounding.
Inputs You Must Provide#
The observed data input is the usual emtools sites argument:
a directory containing EDI files,
one EDI-like object,
a
Sitescontainer,an iterable of site-like objects.
The prediction inputs can be shaped in three ways:
Input shape |
Meaning |
|---|---|
scalar |
Broadcast one value to every station and frequency. |
one array |
Reuse the same per-frequency array for each station. |
|
Use station-specific arrays keyed by station name. |
For real work, the dictionary form is usually best. Each array must be aligned with that station’s frequency array. When a station key is missing from the dictionary, that station is skipped.
Pure Coverage Score#
coverage_score is the pure arithmetic helper. It does not load EDI
files. Use it when you already have observed values and interval bounds.
1import numpy as np
2
3from pycsamt.emtools.diag import coverage_score
4
5rho_obs = np.array([98.0, 105.0, 87.0, 130.0, 112.0])
6q_lo = np.array([90.0, 95.0, 90.0, 100.0, 100.0])
7q_hi = np.array([110.0, 115.0, 100.0, 120.0, 125.0])
8
9score = coverage_score(rho_obs, q_lo, q_hi)
10print(f"empirical coverage = {score:.2f}")
empirical coverage = 0.60
Line 9 computes the fraction of observations that fall inside their
interval. In this toy example, values below q_lo or above q_hi
count as misses.
Building Example Bounds#
The rest of the workflow needs prediction intervals. The example below builds a simple baseline from real L18PLT observations: a rolling median in log-resistivity space becomes the center line, and the interval width grows toward longer periods.
This is not a forecasting model. It is a transparent way to demonstrate the diagnostics using real observed EDI data.
1import numpy as np
2
3from pycsamt.emtools.diag import rho_coverage
4
5survey = "data/AMT/WILLY_DATA/L18PLT"
6
7raw = rho_coverage(
8 survey,
9 q_lo=0.0,
10 q_hi=np.inf,
11 rho_comp="xy",
12)
13
14q_lo = {}
15q_hi = {}
16model = {}
17
18log_period = np.log10(raw["period_s"])
19p_min = log_period.min()
20p_max = log_period.max()
21
22for station, group in raw.groupby("station", sort=False):
23 group = group.reset_index(drop=True)
24 smooth = (
25 np.log10(group["rho_obs"])
26 .rolling(5, center=True, min_periods=1)
27 .median()
28 )
29 center = 10.0 ** smooth.to_numpy()
30 t = (np.log10(group["period_s"]) - p_min) / (p_max - p_min + 1e-12)
31 half_width = 0.15 + 0.30 * t
32
33 q_lo[station] = center * (1.0 - half_width)
34 q_hi[station] = center * (1.0 + half_width)
35 model[station] = center
Lines 7-12 use rho_coverage with infinite bounds as a convenient
way to extract observed apparent resistivity. Lines 31-33 build
station-specific lower bounds, upper bounds, and point predictions.
Per-Frequency Coverage#
Use rho_coverage when you need one row per station and frequency.
1from pycsamt.emtools.diag import rho_coverage
2
3detail = rho_coverage(
4 "data/AMT/WILLY_DATA/L18PLT",
5 q_lo=q_lo,
6 q_hi=q_hi,
7 rho_comp="xy",
8 recursive=True,
9 on_dup="replace",
10 strict=False,
11 verbose=0,
12)
13
14print(detail.head())
15detail.to_csv("l18plt_coverage_detail.csv", index=False)
station freq_hz period_s ... q_hi covered width_pct
0 18-001A 10400.0 0.000096 ... 83.967382 True 32.193022
1 18-001A 8707.0 0.000115 ... 85.549285 True 31.582076
2 18-001A 7289.0 0.000137 ... 87.159020 True 32.307651
3 18-001A 6102.0 0.000164 ... 101.271129 True 33.461671
4 18-001A 5108.0 0.000196 ... 112.968367 True 34.616073
[5 rows x 8 columns]
The output columns are:
station: station name.freq_hzandperiod_s: frequency and inverse frequency.rho_obs: observed apparent resistivity fromZxyorZyx.q_loandq_hi: prediction interval bounds.covered:Truewhenq_lo <= rho_obs <= q_hi.width_pct: interval width as a percentage ofrho_obs.
The most common mistake is misalignment. If your q_lo and q_hi
arrays are not ordered the same way as the station’s frequency array,
coverage will be meaningless even though the code can still run.
Single-Station Inspection#
Before trusting summary statistics, inspect one station’s observed curve against its bounds.
1import matplotlib.pyplot as plt
2
3station = "18-001A"
4one = detail.loc[detail["station"] == station].sort_values("period_s")
5
6fig, ax = plt.subplots(figsize=(7, 4.5))
7ax.fill_between(
8 one["period_s"],
9 one["q_lo"],
10 one["q_hi"],
11 color="0.85",
12 label="predicted interval",
13)
14ax.loglog(one["period_s"], one["rho_obs"], "o-", label="observed")
15ax.scatter(
16 one.loc[~one["covered"], "period_s"],
17 one.loc[~one["covered"], "rho_obs"],
18 color="red",
19 zorder=4,
20 label="miss",
21)
22ax.set_xlabel("Period (s)")
23ax.set_ylabel("Apparent resistivity (ohm.m)")
24ax.set_title(f"{station} observed resistivity vs. prediction interval")
25ax.legend()
26fig.tight_layout()
Red points are misses. A few isolated misses may be acceptable. A whole frequency band outside the interval usually means the model is biased or the interval width is too narrow in that part of the spectrum.
Per-Station Coverage Table#
Use coverage_table to summarize each station.
1from pycsamt.emtools.diag import coverage_table
2
3table = coverage_table(
4 "data/AMT/WILLY_DATA/L18PLT",
5 q_lo=q_lo,
6 q_hi=q_hi,
7 rho_comp="xy",
8 nominal=0.9,
9)
10
11ranked = table.sort_values("empirical_cov")
12print(ranked.head(10))
station n_freq empirical_cov mean_width_pct calibrated_flag
20 18-021B 53 0.754717 61.136034 False
23 18-022V 53 0.811321 61.889193 False
26 18-024U 53 0.830189 62.828105 False
22 18-022U 53 0.830189 61.305808 False
13 18-014A 53 0.867925 66.977547 False
18 18-019U 53 0.867925 62.615552 False
14 18-015U 53 0.886792 62.958395 False
0 18-001A 53 0.886792 63.019031 False
24 18-023A 53 0.886792 61.066131 False
1 18-002U 53 0.886792 63.691545 False
The output columns are:
station: station name.n_freq: number of evaluated frequencies.empirical_cov: fraction of covered frequencies.mean_width_pct: mean interval width as percent of observed resistivity.calibrated_flag:Truewhenempirical_cov >= nominal.
A station can be calibrated because the model is genuinely good, or
because the intervals are very wide. Always read empirical_cov with
mean_width_pct.
Coverage Visualization#
plot_polar_coverage maps frequency to polar angle and observed
resistivity to radius. Green points are covered. Red points are misses.
Thin radial segments show the prediction interval.
1import matplotlib.pyplot as plt
2
3from pycsamt.emtools.diag import plot_polar_coverage
4
5fig, ax = plt.subplots(
6 subplot_kw={"projection": "polar"},
7 figsize=(7, 7),
8)
9plot_polar_coverage(
10 "data/AMT/WILLY_DATA/L18PLT",
11 q_lo=q_lo,
12 q_hi=q_hi,
13 rho_comp="xy",
14 n_freq_ticks=8,
15 ax=ax,
16)
17fig.tight_layout()
This plot is useful when you want to know whether misses cluster in a specific part of the frequency band. A red wedge suggests a systematic frequency-dependent calibration problem. Scattered red points suggest local noise or station-specific departures.
Width Drift#
plot_width_drift bins relative interval width by frequency band.
It answers a different question from coverage: how expensive was the
coverage in interval width?
1import matplotlib.pyplot as plt
2
3from pycsamt.emtools.diag import plot_width_drift
4
5fig, ax_cart = plt.subplots(figsize=(8, 4))
6plot_width_drift(
7 "data/AMT/WILLY_DATA/L18PLT",
8 q_lo=q_lo,
9 q_hi=q_hi,
10 n_bands=8,
11 polar=False,
12 ax=ax_cart,
13)
14
15fig2, ax2 = plt.subplots(
16 subplot_kw={"projection": "polar"},
17 figsize=(6, 6),
18)
19plot_width_drift(
20 "data/AMT/WILLY_DATA/L18PLT",
21 q_lo=q_lo,
22 q_hi=q_hi,
23 n_bands=8,
24 polar=True,
25 ax=ax2,
26)
If widths grow toward lower frequencies, uncertainty is increasing with longer periods and, approximately, with greater investigation depth. If widths are huge everywhere, high coverage may not be very informative.
Point-Prediction Error#
Use rho_error_stats and plot_polar_errors when you have a point
prediction, not only interval bounds.
1from pycsamt.emtools.diag import rho_error_stats, plot_polar_errors
2
3errors = rho_error_stats(
4 "data/AMT/WILLY_DATA/L18PLT",
5 model_rho=model,
6 rho_comp="xy",
7)
8
9print(errors[["station", "freq_hz", "rel_err_pct", "abs_err_pct"]].head())
10
11ax = plot_polar_errors(
12 "data/AMT/WILLY_DATA/L18PLT",
13 model_rho=model,
14 rho_comp="xy",
15 n_bins=18,
16)
station freq_hz rel_err_pct abs_err_pct
0 18-001A 10400.0 7.310073e+00 7.310073e+00
1 18-001A 8707.0 1.375503e+00 1.375503e+00
2 18-001A 7289.0 -1.893832e-14 1.893832e-14
3 18-001A 6102.0 1.638024e-14 1.638024e-14
4 18-001A 5108.0 0.000000e+00 0.000000e+00
The output columns are:
rho_obs: observed apparent resistivity.rho_pred: predicted apparent resistivity.rel_err_pct: signed relative error.abs_err_pct: absolute relative error.
The polar error plot uses red bars for over-prediction and blue bars for under-prediction. Bar height is mean absolute relative error in each frequency sector.
Comparing Calibration Scenarios#
A useful diagnostic exercise is to compare sensible, overconfident, and underconfident intervals.
1import pandas as pd
2
3from pycsamt.emtools.diag import coverage_table
4
5scenarios = {
6 "sensible": 1.0,
7 "overconfident": 0.4,
8 "underconfident": 3.0,
9}
10
11rows = []
12for name, multiplier in scenarios.items():
13 lo_s = {}
14 hi_s = {}
15 for station in q_lo:
16 center = model[station]
17 lo_s[station] = center - (center - q_lo[station]) * multiplier
18 hi_s[station] = center + (q_hi[station] - center) * multiplier
19 t = coverage_table(
20 "data/AMT/WILLY_DATA/L18PLT",
21 q_lo=lo_s,
22 q_hi=hi_s,
23 )
24 rows.append(
25 {
26 "scenario": name,
27 "mean_coverage": t["empirical_cov"].mean(),
28 "mean_width_pct": t["mean_width_pct"].mean(),
29 "n_calibrated": int(t["calibrated_flag"].sum()),
30 }
31 )
32
33comparison = pd.DataFrame(rows)
34print(comparison)
scenario mean_coverage mean_width_pct n_calibrated
0 sensible 0.908356 62.287196 18
1 overconfident 0.766846 24.914878 0
2 underconfident 0.986523 186.861587 28
Read this table as a trade-off. Overconfident intervals should have low coverage and narrow width. Underconfident intervals should have high coverage and wide intervals. A useful model is the one that reaches the target coverage without making the intervals unnecessarily wide.
Lines 15-19 scale the interval half-width around the same point prediction. That keeps the comparison fair: only the uncertainty width changes, not the model center.
Reading The Results#
Use this interpretation order:
Check
coverage_tablefirst for station-level calibration.Read
mean_width_pctbesideempirical_cov.Use
plot_polar_coverageto locate frequency bands where misses cluster.Use
plot_width_driftto see whether the model becomes less certain at longer periods.Use
plot_polar_errorsto identify over- or under-prediction sectors when a point prediction is available.
Common Failure Modes#
- Missing prediction keys
If
q_loorq_hiis a dictionary and a station key is absent, that station is skipped. Check the station names in your prediction output.- Mismatched array length
Prediction arrays must align with the loaded station frequency array. Build bounds from the same station order and frequency order used by pyCSAMT.
- Intervals with high coverage but huge width
This is underconfidence. The model is technically calibrated but not very useful.
- Intervals with narrow width and low coverage
This is overconfidence. The model misses too many observations for the claimed interval.
- Scalar bounds
Scalars are accepted for quick tests, but they are rarely meaningful for real apparent-resistivity uncertainty because resistivity varies strongly across frequency and station.
Saving A Reproducible Diagnostic Bundle#
Save the detailed coverage table, station summary, error table, and figures together.
1from pathlib import Path
2
3import matplotlib.pyplot as plt
4
5from pycsamt.emtools.diag import (
6 coverage_table,
7 plot_polar_coverage,
8 plot_width_drift,
9 rho_coverage,
10 rho_error_stats,
11)
12
13survey = "data/AMT/WILLY_DATA/L18PLT"
14out = Path("outputs/diag_l18plt")
15out.mkdir(parents=True, exist_ok=True)
16
17detail = rho_coverage(survey, q_lo=q_lo, q_hi=q_hi)
18table = coverage_table(survey, q_lo=q_lo, q_hi=q_hi)
19errors = rho_error_stats(survey, model_rho=model)
20
21detail.to_csv(out / "coverage_detail.csv", index=False)
22table.to_csv(out / "coverage_table.csv", index=False)
23errors.to_csv(out / "relative_errors.csv", index=False)
24
25fig1, ax1 = plt.subplots(subplot_kw={"projection": "polar"}, figsize=(7, 7))
26plot_polar_coverage(survey, q_lo=q_lo, q_hi=q_hi, ax=ax1)
27fig1.savefig(out / "polar_coverage.png", dpi=200)
28
29fig2, ax2 = plt.subplots(figsize=(8, 4))
30plot_width_drift(survey, q_lo=q_lo, q_hi=q_hi, ax=ax2)
31fig2.savefig(out / "width_drift.png", dpi=200)
Worked Example#
The gallery example uses L18PLT from data/AMT/WILLY_DATA/ and
builds synthetic prediction intervals around real observed apparent
resistivity. It demonstrates one-station inspection, per-station
coverage ranking, polar coverage, width drift, polar errors, and a
calibration scenario comparison.
Open the rendered example here: Polar uncertainty diagnostics (pycsamt.emtools.diag).