Offshore pickers on ocean-bottom seismometers¶
QuakeScope will process ocean-bottom data, and ocean-bottom data is not land data with more noise. The instrument sits under a water column that reverberates, often on soft sediment, with horizontals that are arbitrarily oriented because nobody was there to point them north. Most stations also carry a hydrophone, a channel that has no land equivalent at all.
SeisBench ships three ocean-bottom pickers, and the differences between them are the interesting part:
| Model | Backbone | Components | Hydrophone |
|---|---|---|---|
PickBlue(base="phasenet") |
PhaseNet | Z12H |
yes |
PickBlue(base="eqtransformer") |
EQTransformer | Z12H |
yes |
OBSTransformer (Niksejel & Zhang, 2024) |
EQTransformer | ZNE |
no |
PickBlue is a constructor rather than a model — it returns the obs
weights on whichever backbone you ask for. OBSTransformer is the useful
control: trained on ocean-bottom data but taking only three components,
which separates trained offshore from uses the fourth channel.
Two land models quantify what running them offshore costs. All five see identical windows on three deployments in contrasting settings.
| Deployment | Network | When | Setting |
|---|---|---|---|
| Cascadia Initiative | 7D | 2012–13 | Subduction margin, offshore northern California |
| AACSE | XO | 2018–19 | Alaska Peninsula subduction, shelf to trench |
| Blanco | X9 | 2012–13 | Oceanic transform, strike-slip in young crust |
How detection is scored, and why not against analyst picks¶
Regional catalogs do not pick temporary OBS deployments, so there are no analyst arrivals at these stations to score against. Instead each catalog event gets a predicted P arrival from iasp91, and a model counts as having detected it if it places a P pick within a tolerance of that time.
That prediction is the weak link and the tolerance has to absorb it: iasp91 has no water layer and no sediment column, both of which delay the true arrival relative to the model. Treat the numbers as relative between weight sets on identical data, not as absolute detection rates.
from collections import defaultdict
import matplotlib.pyplot as plt
import numpy as np
import obspy
import pandas as pd
import seisbench.models as sbm
from obspy import UTCDateTime
from obspy.clients.fdsn import Client
from obspy.geodetics import gps2dist_azimuth, locations2degrees
from obspy.taup import TauPyModel
%matplotlib inline
1. Configuration¶
# Three deployments, chosen for contrasting settings and instrumentation.
# Stations were selected by confirming all four components actually return
# data, not from metadata - and the hydrophone sampling rate differs by an
# order of magnitude between experiments, which section 6 tests the effect of.
EXPERIMENTS = {
"Cascadia (7D)": dict(
network="7D", start=UTCDateTime("2012-09-01"), end=UTCDateTime("2013-05-01"),
lat=40.3, lon=-124.8, min_mag=3.5, max_radius_deg=2.0,
stations=[("FS07B", "HH", "HDH"), ("FS06B", "BH", "BDH"), ("FS05B", "BH", "BDH")],
note="Cascadia subduction margin, offshore northern California",
),
"AACSE (XO)": dict(
network="XO", start=UTCDateTime("2018-08-01"), end=UTCDateTime("2019-06-01"),
lat=54.8, lon=-155.5, min_mag=3.5, max_radius_deg=2.0,
stations=[("LA23", "HH", "EDH"), ("LA25", "HH", "EDH"), ("LD36", "HH", "HDH")],
note="Alaska Peninsula subduction zone, shelf and trench",
),
"Blanco (X9)": dict(
network="X9", start=UTCDateTime("2012-10-01"), end=UTCDateTime("2013-08-01"),
lat=43.1, lon=-126.4, min_mag=3.5, max_radius_deg=2.0,
stations=[("BB060", "HH", "BDH"), ("BB090", "HH", "BDH"), ("BB070", "HH", "BDH")],
note="Blanco oceanic transform - strike-slip in young oceanic crust",
),
}
# SeisBench ships three ocean-bottom pickers and they differ in ways that
# matter here.
#
# PickBlue is a constructor, not a model: PickBlue(base=...) returns the
# "obs" weights on either a PhaseNet or an EQTransformer backbone. Both take
# four components, Z12H, the last being the hydrophone.
#
# OBSTransformer (Niksejel & Zhang, 2024) is also OBS-trained but takes only
# three components - no hydrophone. It is the control that separates
# "trained on ocean-bottom data" from "uses the fourth channel".
#
# The land models quantify what running them offshore costs.
MODEL_SPECS = {
"pickblue_phasenet": dict(build=lambda: sbm.PickBlue(base="phasenet"),
hydrophone=True),
"pickblue_eqt": dict(build=lambda: sbm.PickBlue(base="eqtransformer"),
hydrophone=True),
"obstransformer": dict(build=lambda: sbm.OBSTransformer.from_pretrained("obst2024"),
hydrophone=False),
"quakescope2026": dict(build=lambda: sbm.PhaseNet.from_pretrained("quakescope2026"),
hydrophone=False),
"original": dict(build=lambda: sbm.PhaseNet.from_pretrained("original"),
hydrophone=False),
}
# Detection is scored against a predicted arrival rather than an analyst pick,
# because regional catalogs do not pick temporary OBS stations. iasp91 has no
# water layer and no sediments, so the prediction is systematically early at
# these sites and the tolerance has to be generous.
ABLATION_MODEL = "pickblue_phasenet" # the 4-component model used in section 6
GALLERY_N = 10 # windows shown per deployment in section 7
TOLERANCE = 10.0 # seconds around the predicted P
PRE, POST = 60, 120 # window around the predicted arrival
DETECT_FLOOR = 0.02 # run once here, threshold offline
REPORT_THRESHOLD = 0.3
COLORS = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100"]
C_P, C_S = "#2a78d6", "#eb6834"
2. Data access and arrival prediction¶
These are temporary networks with no public S3 bucket, so everything comes over EarthScope FDSN.
_es = Client("EARTHSCOPE", timeout=300)
_usgs = Client("USGS", timeout=300)
_taup = TauPyModel(model="iasp91")
_station_coords = {}
def station_coords(net, sta, t0, t1):
"""Coordinates for an OBS station, cached."""
key = (net, sta)
if key not in _station_coords:
inv = _es.get_stations(network=net, station=sta, level="station",
starttime=t0, endtime=t1)
s = inv[0][0]
_station_coords[key] = (s.latitude, s.longitude)
return _station_coords[key]
def predicted_p(origin, slat, slon):
"""iasp91 P arrival at the station. None if no ray reaches it."""
deg = locations2degrees(slat, slon, origin.latitude, origin.longitude)
depth = max((origin.depth or 0) / 1000.0, 0.0)
arrivals = _taup.get_travel_times(source_depth_in_km=depth,
distance_in_degree=deg, phase_list=["p", "P"])
if not arrivals:
return None, deg
return origin.time + arrivals[0].time, deg
def fetch_obs(net, sta, band, hydro, t0, t1):
"""Four-component OBS stream: Z, 1, 2 and the hydrophone."""
try:
st = _es.get_waveforms(net, sta, "*", f"{band}?,{hydro}", t0, t1)
except Exception:
return None
st.merge(fill_value=0)
comps = {tr.stats.channel[-1] for tr in st}
if not {"Z", "1", "2"} <= comps:
return None
return st
def as_three_component(st):
"""Land models expect ZNE. OBS horizontals are 1 and 2, arbitrarily
oriented; renaming them is the usual convention and costs nothing here
because neither model uses absolute orientation."""
out = obspy.Stream()
for tr in st:
if tr.stats.channel[-1] == "H": # drop the hydrophone
continue
tr = tr.copy()
if tr.stats.channel[-1] == "1":
tr.stats.channel = tr.stats.channel[:-1] + "N"
elif tr.stats.channel[-1] == "2":
tr.stats.channel = tr.stats.channel[:-1] + "E"
out += tr
return out
3. Load the weight sets¶
models, needs_h = {}, {}
for name, spec in MODEL_SPECS.items():
try:
m = spec['build']()
except Exception as exc:
print(f"{name:<20} could not load ({type(exc).__name__}) - skipping")
continue
models[name] = m
needs_h[name] = spec['hydrophone']
print(f"{name:<20} {type(m).__name__:<15} comps={m.component_order:<6} "
f"in_samples={m.in_samples:<5} hydrophone={needs_h[name]}")
names = list(models)
pickblue_phasenet PhaseNet comps=Z12H in_samples=3001 hydrophone=True pickblue_eqt EQTransformer comps=Z12H in_samples=6000 hydrophone=True obstransformer OBSTransformer comps=ZNE in_samples=6000 hydrophone=False quakescope2026 PhaseNet comps=ZNE in_samples=3001 hydrophone=False original PhaseNet comps=ENZ in_samples=3001 hydrophone=False
4. Run every model over every deployment¶
One short window per catalogued event per station. The slow part is the network, not the inference.
records = []
examples, gallery = {}, {}
for label, exp in EXPERIMENTS.items():
print(f"{label} - {exp['note']}")
try:
cat = _usgs.get_events(starttime=exp['start'], endtime=exp['end'],
latitude=exp['lat'], longitude=exp['lon'],
maxradius=exp['max_radius_deg'],
minmagnitude=exp['min_mag'])
except Exception as exc:
print(f" catalog failed: {type(exc).__name__}")
continue
print(f" {len(cat)} catalogued events M>={exp['min_mag']}")
for sta, band, hydro in exp['stations']:
try:
slat, slon = station_coords(exp['network'], sta, exp['start'], exp['end'])
except Exception as exc:
print(f" {sta}: metadata failed ({type(exc).__name__})")
continue
n_win = 0
for ev in cat:
origin = ev.preferred_origin() or (ev.origins[0] if ev.origins else None)
if origin is None:
continue
tp, deg = predicted_p(origin, slat, slon)
if tp is None:
continue
st = fetch_obs(exp['network'], sta, band, hydro, tp - PRE, tp + POST)
if st is None:
continue
n_win += 1
st3 = as_three_component(st)
mag = ev.preferred_magnitude() or ev.magnitudes[0]
has_h = any(tr.stats.channel[-1] == 'H' for tr in st)
win_picks = {}
for name, model in models.items():
use = st if needs_h[name] else st3
try:
out = model.classify(use, P_threshold=DETECT_FLOOR,
S_threshold=DETECT_FLOOR)
except Exception:
continue
best = None
for p in out.picks:
if p.phase != 'P':
continue
dt = p.peak_time - tp
if abs(dt) <= TOLERANCE and (best is None or
float(p.peak_value) > best[1]):
best = (dt, float(p.peak_value))
if best:
win_picks[name] = best
records.append(dict(
experiment=label, station=sta, weights=name,
mag=round(mag.mag, 1), dist_deg=round(deg, 3),
has_hydrophone=has_h,
dt=round(best[0], 2) if best else np.nan,
conf=round(best[1], 3) if best else 0.0,
n_picks=len(out.picks),
))
if name == ABLATION_MODEL and best and best[1] >= REPORT_THRESHOLD:
examples.setdefault(label, []).append(
(st, tp, sta, mag.mag, best))
per_sta = sum(1 for g in gallery.get(label, []) if g['sta'] == sta)
room = int(np.ceil(GALLERY_N / max(len(exp['stations']), 1)))
if per_sta < room and len(gallery.get(label, [])) < GALLERY_N + room:
keep = st.slice(tp - 30, tp + 70).copy()
gallery.setdefault(label, []).append(dict(
stream=keep, tp=tp, sta=sta, mag=mag.mag,
deg=deg, picks=dict(win_picks)))
print(f" {sta:<6} {n_win} windows with data")
print()
det = pd.DataFrame(records)
print(f"{len(det)} (event, station, model) rows")
Cascadia (7D) - Cascadia subduction margin, offshore northern California
19 catalogued events M>=3.5
FS07B 19 windows with data
FS06B 19 windows with data
FS05B 19 windows with data AACSE (XO) - Alaska Peninsula subduction zone, shelf and trench
12 catalogued events M>=3.5
LA23 12 windows with data
LA25 12 windows with data
LD36 12 windows with data Blanco (X9) - Blanco oceanic transform - strike-slip in young oceanic crust
15 catalogued events M>=3.5
BB060 15 windows with data
BB090 15 windows with data
BB070 15 windows with data 690 (event, station, model) rows
5. Detection rate¶
A detection is a P pick within the tolerance of the predicted arrival, counted at the reporting threshold.
hit = det[det.conf >= REPORT_THRESHOLD]
rows = []
for label in EXPERIMENTS:
sub_all = det[det.experiment == label]
if not len(sub_all):
continue
for name in names:
total = len(sub_all[sub_all.weights == name])
found = len(hit[(hit.experiment == label) & (hit.weights == name)])
if not total:
continue
res = hit[(hit.experiment == label) & (hit.weights == name)]['dt']
rows.append(dict(experiment=label, weights=name,
windows=total, detected=found,
rate=round(found / total, 3),
median_dt=round(float(res.median()), 2) if len(res) else np.nan))
summary = pd.DataFrame(rows)
print(summary.to_string(index=False))
print(f'\ndetection = a P pick within {TOLERANCE:g} s of the iasp91 prediction, '
f'at confidence >= {REPORT_THRESHOLD}')
print('median_dt = median offset from the prediction. What matters is that it '
'is small and similar across models, which says they lock onto the same '
'arrival and the offset belongs to the prediction rather than the picker.')
experiment weights windows detected rate median_dt Cascadia (7D) pickblue_phasenet 57 42 0.737 -1.22 Cascadia (7D) pickblue_eqt 57 45 0.789 -1.26 Cascadia (7D) obstransformer 57 35 0.614 -1.10 Cascadia (7D) quakescope2026 57 41 0.719 -0.93 Cascadia (7D) original 57 37 0.649 -0.93 AACSE (XO) pickblue_phasenet 36 28 0.778 -1.49 AACSE (XO) pickblue_eqt 36 26 0.722 -1.59 AACSE (XO) obstransformer 36 32 0.889 -1.69 AACSE (XO) quakescope2026 36 28 0.778 -1.43 AACSE (XO) original 36 24 0.667 -1.32 Blanco (X9) pickblue_phasenet 45 24 0.533 -3.42 Blanco (X9) pickblue_eqt 45 24 0.533 -3.39 Blanco (X9) obstransformer 45 23 0.511 -3.11 Blanco (X9) quakescope2026 45 24 0.533 -2.83 Blanco (X9) original 45 22 0.489 -3.31 detection = a P pick within 10 s of the iasp91 prediction, at confidence >= 0.3 median_dt = median offset from the prediction. What matters is that it is small and similar across models, which says they lock onto the same arrival and the offset belongs to the prediction rather than the picker.
if len(summary):
labels = [l for l in EXPERIMENTS if l in set(summary.experiment)]
fig, ax = plt.subplots(figsize=(10.5, 4.3))
width = 0.8 / max(len(names), 1)
for i, name in enumerate(names):
xs, vals = [], []
for j, lab in enumerate(labels):
r = summary[(summary.experiment == lab) & (summary.weights == name)]
if len(r):
xs.append(j + (i - (len(names) - 1) / 2) * width)
vals.append(float(r['rate'].iloc[0]))
ax.bar(xs, vals, width=width * 0.9, color=COLORS[i % len(COLORS)], label=name)
for x, v in zip(xs, vals):
ax.text(x, v + 0.015, f'{v:.2f}', ha='center', fontsize=8)
ax.set_xticks(range(len(labels)))
ax.set_xticklabels(labels, fontsize=9)
ax.set_ylabel('fraction of catalogued events detected')
ax.set_ylim(0, 1.05)
ax.grid(alpha=0.25, lw=0.5, axis='y')
ax.set_axisbelow(True)
ax.legend(frameon=False, fontsize=9, ncol=len(names))
ax.set_title('Detection of catalogued events on ocean-bottom stations',
fontsize=11, loc='left')
fig.tight_layout()
plt.show()
Where the picks land relative to the prediction¶
What matters is that the cluster is tight and in the same place for every model — that indicates they are all locking onto the same physical arrival, and that the offset is a property of the prediction rather than of any weight set.
The offset direction is not a clean diagnostic here. A water column and sediments delay the true arrival relative to iasp91, but offshore catalog locations and depths are themselves poorly constrained — many are fixed rather than solved — and that error enters the prediction directly and in either direction. A consistent offset of a second or two says the prediction is biased, not that the picks are wrong.
fig, ax = plt.subplots(figsize=(10.5, 3.8))
bins = np.linspace(-TOLERANCE, TOLERANCE, 33)
for name, color in zip(names, COLORS):
d = hit[hit.weights == name]['dt'].dropna()
if not len(d):
continue
ax.hist(d, bins=bins, histtype='step', lw=1.8, color=color,
label=f'{name} (n={len(d)})')
ax.axvline(0, color='#8a8a8a', lw=1)
ax.set_xlabel('pick minus iasp91 prediction (s)')
ax.set_ylabel('detections')
ax.set_title('Offset from the predicted arrival', fontsize=11, loc='left')
ax.grid(alpha=0.25, lw=0.5)
ax.legend(frameon=False, fontsize=9)
fig.tight_layout()
plt.show()
6. Does the hydrophone earn its place?¶
obs is the only model here that can use the fourth channel. The
hydrophone is also the channel whose sampling rate varies most between
these deployments — 100 Hz at AACSE, 40 Hz at Cascadia, 10 Hz at
Blanco — while the model resamples everything to 100 Hz. An upsampled
10 Hz trace carries very little of the band a P onset lives in.
Re-running obs on the same windows with the hydrophone withheld
separates the value of the channel from the value of the training.
if ABLATION_MODEL in models:
rows = []
for label, entries in examples.items():
for st, tp, sta, mag, best in entries:
with_h = st
without_h = obspy.Stream([tr for tr in st if tr.stats.channel[-1] != 'H'])
entry = {'experiment': label, 'station': sta, 'M': mag}
for tag, stream in (('with_H', with_h), ('without_H', without_h)):
out = models[ABLATION_MODEL].classify(stream, P_threshold=DETECT_FLOOR,
S_threshold=DETECT_FLOOR)
cand = [float(p.peak_value) for p in out.picks
if p.phase == 'P' and abs(p.peak_time - tp) <= TOLERANCE]
entry[tag] = round(max(cand), 3) if cand else 0.0
entry['hydrophone_rate'] = next(
(tr.stats.sampling_rate for tr in st if tr.stats.channel[-1] == 'H'), np.nan)
rows.append(entry)
abl = pd.DataFrame(rows)
if len(abl):
abl['delta'] = (abl.with_H - abl.without_H).round(3)
print(f'{len(abl)} detected windows re-run with the hydrophone withheld\n')
print(abl.groupby(['experiment', 'hydrophone_rate'])[['with_H', 'without_H', 'delta']]
.agg(['mean', 'count']).round(3).to_string())
print(f'\nmean change in P confidence when the hydrophone is included: '
f'{abl.delta.mean():+.4f}')
worse = int((abl.delta < -0.01).sum()); better = int((abl.delta > 0.01).sum())
print(f'windows where it helped by >0.01: {better}; hurt by >0.01: {worse}; '
f'unchanged: {len(abl) - better - worse}')
else:
print('no detections captured for the ablation')
94 detected windows re-run with the hydrophone withheld
with_H without_H delta
mean count mean count mean count
experiment hydrophone_rate
AACSE (XO) 100.0 0.807 28 0.866 28 -0.059 28
Blanco (X9) 10.0 0.855 24 0.876 24 -0.022 24
Cascadia (7D) 40.0 0.772 30 0.693 30 0.079 30
125.0 0.699 12 0.714 12 -0.015 12
mean change in P confidence when the hydrophone is included: +0.0002
windows where it helped by >0.01: 32; hurt by >0.01: 35; unchanged: 27
7. Scanning the records¶
Ten windows per deployment, spread across its stations rather than drawn from one, vertical component, with every model's pick overlaid so disagreements are visible at a glance. The dotted line is the iasp91 prediction, which per section 5 carries the catalog's location error and should be read as an approximate marker rather than truth.
What to look for: picks clustering on a visible onset is the model working; picks spread across seconds of an emergent arrival is the hard case that ocean-bottom noise creates; and a pick with nothing visible under it is worth following up.
def gallery_figure(label, entries, window=(-25, 60)):
n = len(entries)
fig, axes = plt.subplots(n, 1, figsize=(11.5, 1.35 * n + 1.0), sharex=True)
axes = np.atleast_1d(axes)
for ax, e in zip(axes, entries):
tr = e['stream'].select(component='Z')
if not tr:
continue
tr = tr[0]
t = tr.times(reftime=e['tp'])
x = tr.data.astype(float)
peak = np.abs(x).max()
if peak > 0:
x = x / peak
ax.plot(t, x, color='#3d3d3d', lw=0.5)
ax.axvline(0, color='#8a8a8a', lw=1.1, ls=':')
for i, name in enumerate(names):
if name not in e['picks']:
continue
dt, conf = e['picks'][name]
ax.axvline(dt, color=COLORS[i % len(COLORS)], lw=1.5,
alpha=0.85 if conf >= REPORT_THRESHOLD else 0.35)
found = len(e['picks'])
ax.set_ylabel(f"{e['sta']}\nM{e['mag']:.1f} {e['deg']*111:.0f}km",
fontsize=7.5, rotation=0, ha='right', va='center', labelpad=32)
ax.set_yticks([])
ax.grid(alpha=0.18, lw=0.4, axis='x')
ax.text(0.995, 0.82, f'{found}/{len(names)} models',
transform=ax.transAxes, ha='right', fontsize=7, color='#7a7973')
handles = [plt.Line2D([], [], color=COLORS[i % len(COLORS)], lw=1.6, label=n)
for i, n in enumerate(names)]
handles.append(plt.Line2D([], [], color='#8a8a8a', lw=1.1, ls=':',
label='iasp91 prediction'))
axes[0].legend(handles=handles, fontsize=7.5, frameon=False, ncol=3,
loc='lower left', bbox_to_anchor=(0, 1.05))
axes[-1].set_xlim(*window)
axes[-1].set_xlabel('seconds from the predicted P')
fig.suptitle(label, fontsize=11, x=0.01, ha='left', y=0.998)
fig.tight_layout(rect=[0, 0, 1, 0.985])
return fig
for label in EXPERIMENTS:
entries = gallery.get(label) or []
if not entries:
continue
gallery_figure(label, entries[:GALLERY_N])
plt.show()
One record in full¶
All four components for a single detected event per deployment, including the hydrophone, at its native sampling rate.
def plot_obs(label, entry, window=(-30, 90)):
st, tp, sta, mag, best = entry
order = ['Z', '1', '2', 'H']
traces = [next((tr for tr in st if tr.stats.channel[-1] == c), None) for c in order]
traces = [t for t in traces if t is not None]
fig, axes = plt.subplots(len(traces), 1, figsize=(11, 1.5 * len(traces) + 1.1),
sharex=True)
axes = np.atleast_1d(axes)
for ax, tr in zip(axes, traces):
ax.plot(tr.times(reftime=tp), tr.data, color='#3d3d3d', lw=0.5)
ax.axvline(0, color='#8a8a8a', lw=1.2, ls=':')
ax.axvline(best[0], color=C_P, lw=1.6)
ax.set_ylabel(f'{tr.stats.channel}\n{tr.stats.sampling_rate:g} Hz', fontsize=8)
ax.grid(alpha=0.22, lw=0.5)
ax.tick_params(labelsize=8)
axes[-1].set_xlim(*window)
axes[-1].set_xlabel('seconds from the iasp91 predicted P '
'(dotted); solid line is the pick')
axes[0].set_title(f'{label} - {sta} - M{mag} - '
f'pick {best[0]:+.1f} s at confidence {best[1]:.2f}',
fontsize=10, loc='left')
fig.tight_layout()
return fig
for label in EXPERIMENTS:
entries = examples.get(label) or []
if entries:
plot_obs(label, max(entries, key=lambda e: e[4][1]))
plt.show()
Reading the result¶
The comparison is relative, not absolute. Detection is scored against an iasp91 prediction with no water layer and no sediments, on a tolerance wide enough to absorb that. What the numbers support is which weight set does better on identical windows; what they do not support is a claim about how complete an OBS catalog would be.
The land models are being used out of domain on purpose. That is the
measurement: QuakeScope will encounter ocean-bottom data, and the question
is what running a land-trained picker over it costs. Renaming the
horizontals from 1/2 to N/E is the usual convention and is
harmless here, since none of these models uses absolute orientation.
Instrumentation varies more than the settings do. Hydrophone sampling runs from 100 Hz to 10 Hz across three deployments, and seismometer bands differ too. A weight set that depends on the fourth channel will behave differently between experiments for reasons that have nothing to do with the seismicity, which is what section 6 is for.
Thresholds still belong to the weight set. Everything above is
reported at a shared cutoff, and the shared-threshold comparison was
already shown to mislead in the land benchmark — see
phasenet_sequence_comparison.ipynb.
Note that obs ships different defaults from the rest, P 0.2 and S 0.1,
which is itself a signal that its probabilities are not on the same scale.