File size: 14,405 Bytes
8d5a487 3db00a5 8d5a487 3db00a5 8d5a487 3db00a5 8d5a487 3db00a5 193db6d 3db00a5 8d5a487 3db00a5 8d5a487 3db00a5 8d5a487 3db00a5 8d5a487 3db00a5 8d5a487 3db00a5 8d5a487 3db00a5 193db6d 3db00a5 8d5a487 3db00a5 8d5a487 3db00a5 8d5a487 3db00a5 8d5a487 3db00a5 8d5a487 3db00a5 8d5a487 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 | """Gradio app (Hugging Face Spaces): shear-wave velocity prediction from a
phase-velocity dispersion curve with the phase-only DispFormer trained on
OpenSWI-shallow.
Gradio SDK so the Space also works on ZeroGPU hardware (ZeroGPU is
Gradio-only). The model is 2.44 M parameters — inference runs on CPU in
milliseconds, so no GPU decorator is needed.
Run locally with:
python app.py
"""
import os
import tempfile
try:
import spaces # ZeroGPU: must be imported before torch
except ImportError: # local run / CPU Space without the spaces package
spaces = None
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import torch
import gradio as gr
from model import DispersionTransformerAblate
APP_DIR = os.path.dirname(os.path.abspath(__file__))
# proposed model of paper3 (v4): physically-coded encoder-decoder, 2.44 M params
CKPT = os.path.join(APP_DIR, "checkpoints", "best_model.pth")
PERIOD = np.load(os.path.join(APP_DIR, "assets/period_grid.npy"))
DEPTH = np.load(os.path.join(APP_DIR, "assets/depth_grid.npy"))
C1, C3 = 1 / 3, 1 / 2 # wavelength heuristic coefficients (Xia et al., 1999)
MODEL = DispersionTransformerAblate(
model_dim=128, num_heads=8, num_layers=3, output_dim=72,
scale_factor=4.5, local=False, decoder="depthq",
depth_values=DEPTH, decoder_layers=1)
MODEL.load_state_dict(torch.load(CKPT, map_location="cpu"))
MODEL.train() # evaluation protocol of the training pipeline
_EX = np.load(os.path.join(APP_DIR, "assets/examples.npz"))
EX_CURVES, EX_PROFILES = _EX["curves"], _EX["profiles"]
EX_LABELS = [f"{n} #{i}" for i, n in enumerate(_EX["names"])]
DEFAULT_PASTE = "\n".join(f"{t:.3f}, {c:.3f}" for t, c in
zip(PERIOD[::12], EX_CURVES[0][::12]) if c > 0)
K_PRESETS = ["Native (k = 1): 0.2–10 s, 0–2.76 km",
"Engineering ≈ 30 m (k = 100): 2 ms–0.1 s, 0–27.6 m",
"Custom k"]
def resolve_k(preset, k_custom):
if preset.startswith("Custom"):
return float(np.clip(k_custom or 100.0, 1.0, 10000.0))
return 100.0 if preset.startswith("Engineering") else 1.0
def snap_to_grid(periods, velocities):
"""Place picks on the fixed 100-period grid (nearest period; picks that
share a grid node are averaged). Returns (curve on grid, n_used, n_out)."""
grid = np.full(len(PERIOD), -1.0, dtype=np.float32)
counts = np.zeros(len(PERIOD))
sums = np.zeros(len(PERIOD))
n_out = 0
for T, c in zip(periods, velocities):
if not (PERIOD.min() <= T <= PERIOD.max()) or c <= 0:
n_out += 1
continue
j = int(np.abs(PERIOD - T).argmin())
sums[j] += c
counts[j] += 1
used = counts > 0
grid[used] = (sums[used] / counts[used]).astype(np.float32)
return grid, int(used.sum()), n_out
def usable_depth_range(curve):
"""Constrained depth interval from the wavelength heuristic."""
valid = curve > 0
if not valid.any():
return 0, len(DEPTH)
p, v = PERIOD[valid], curve[valid]
dmin = C1 * p.min() * v[p.argmin()]
dmax = C3 * p.max() * v[p.argmax()]
lo = max(0, int(np.abs(DEPTH - dmin).argmin()) - 1)
hi = min(len(DEPTH), int(np.abs(DEPTH - dmax).argmin()) + 1)
return lo, hi
def _gpu(fn):
"""ZeroGPU hardware refuses to start without a @spaces.GPU function.
The model itself runs on CPU in milliseconds, so the short duration just
satisfies the check while keeping queue priority high."""
return spaces.GPU(duration=10)(fn) if spaces is not None else fn
@_gpu
def predict(curve):
x = np.full((1, 3, len(PERIOD)), -1.0, dtype=np.float32)
x[0, 0] = PERIOD
x[0, 1] = curve
x = torch.from_numpy(x)
mask = (x[:, 1] == -1) & (x[:, 2] == -1)
with torch.no_grad():
out = MODEL(x, mask)
return out[0, :len(DEPTH)].numpy()
def render(curve, k, true_vs=None, note=None):
"""Run the inversion and build (input fig, profile fig, summary, CSV)."""
if curve is None or (curve > 0).sum() == 0:
raise gr.Error("No valid picks inside the accepted period band — "
"check the values, units, and scale factor k.")
lines = [] if note is None else [note]
if (curve > 0).sum() < 5:
lines.append("⚠️ Very few valid picks — the prediction will be "
"poorly constrained.")
vmin_meas = float(curve[curve > 0].min())
if vmin_meas < 0.3:
lines.append(
f"⚠️ Lowest measured phase velocity is {vmin_meas*1000:.0f} m/s — "
"below the training support (≈ 0.3–4.5 km/s, unchanged by the "
"scale factor). Typical of soft-soil sites; predictions there are "
"extrapolation and the recommended path is fine-tuning on "
"engineering-scale synthetics (paper3 §6.2).")
vs = predict(curve)
lo, hi = usable_depth_range(curve)
valid = curve > 0
# display-unit helpers: show everything in the user's field units
depth_disp = DEPTH / k
period_disp = PERIOD / k
depth_in_m = depth_disp.max() < 0.2 # show meters for shallow scales
dunit = "m" if depth_in_m else "km"
dsc = 1000.0 if depth_in_m else 1.0
D = depth_disp * dsc
fig1, ax = plt.subplots(figsize=(5.5, 4), constrained_layout=True)
ax.plot(period_disp[valid], curve[valid], "o-", ms=3.5, lw=1.2,
color="#1f77b4")
ax.set_xscale("log")
ax.set_xlabel("period (s)" + (f" [measured; k = {k:g}]" if k != 1 else ""))
ax.set_ylabel("phase velocity (km/s)")
ax.set_title(f"Input curve ({int(valid.sum())} of {len(PERIOD)} grid periods)")
ax.grid(alpha=0.3)
fig2, ax = plt.subplots(figsize=(5.5, 4), constrained_layout=True)
if true_vs is not None:
ax.step(true_vs, D, where="mid", color="k", lw=1.6, label="true Vs")
ax.step(vs, D, where="mid", color="#d62728", lw=1.6, label="predicted Vs")
if lo > 0:
ax.axhspan(0, D[lo], color="gray", alpha=0.15)
if hi < len(DEPTH):
ax.axhspan(D[hi - 1], D[-1], color="gray", alpha=0.15)
ax.invert_yaxis()
ax.set_xlabel("Vs (km/s)")
ax.set_ylabel(f"depth ({dunit})")
ax.set_title("Predicted 1-D Vs profile")
ax.legend(fontsize=8)
ax.grid(alpha=0.3)
if true_vs is not None:
err = vs - true_vs
lines.append(
f"**RMSE vs truth:** {np.sqrt((err ** 2).mean()):.3f} km/s · "
f"**MAE:** {np.abs(err).mean():.3f} km/s · "
f"**MAPE:** {(np.abs(err) / true_vs).mean() * 100:.1f} %")
lines.append(
f"Gray bands mark depths outside the range the input band physically "
f"constrains (wavelength heuristic: ≈ ⅓·λ_min to ½·λ_max → "
f"{D[lo]:.2f}–{D[min(hi, len(DEPTH)) - 1]:.2f} {dunit} here); treat "
f"the profile there as extrapolation."
+ (f" Scale factor k = {k:g}: periods ×{k:g} into the model, depths "
f"÷{k:g} on output; velocities unchanged." if k != 1 else ""))
out_df = pd.DataFrame({f"depth_{dunit}": D, "vs_km_s": vs,
"constrained": [(lo <= i < hi)
for i in range(len(DEPTH))]})
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False,
prefix="predicted_vs_profile_")
out_df.to_csv(tmp.name, index=False)
tmp.close()
return fig1, fig2, "\n\n".join(lines), tmp.name
def invert_example(label, lo_p, hi_p):
idx = EX_LABELS.index(label)
curve = EX_CURVES[idx].copy()
curve[(PERIOD < lo_p) | (PERIOD > hi_p)] = -1.0
return render(curve, 1.0, true_vs=EX_PROFILES[idx])
def invert_csv(file, freq_input, vel_unit, preset, k_custom):
if file is None:
raise gr.Error("Upload a CSV file first.")
k = resolve_k(preset, k_custom)
path = file if isinstance(file, str) else file.name
df = pd.read_csv(path)
if df.shape[1] < 2:
raise gr.Error("The CSV needs at least two columns: period (s) or "
"frequency (Hz), then phase velocity.")
p = pd.to_numeric(df.iloc[:, 0], errors="coerce").to_numpy(dtype=float)
v = pd.to_numeric(df.iloc[:, 1], errors="coerce").to_numpy(dtype=float)
ok = np.isfinite(p) & np.isfinite(v)
p, v = p[ok], v[ok]
if freq_input:
p = np.where(p > 0, 1.0 / p, np.nan)
v = v[np.isfinite(p)]
p = p[np.isfinite(p)]
if vel_unit == "m/s":
v = v / 1000.0
curve, n_used, n_out = snap_to_grid(p * k, v)
note = (f"{n_used} grid periods filled"
+ (f" · {n_out} picks outside the accepted band dropped"
if n_out else ""))
return render(curve, k, note=note)
def invert_paste(text, preset, k_custom):
k = resolve_k(preset, k_custom)
try:
rows = [list(map(float, ln.replace(",", " ").split()))
for ln in text.strip().splitlines() if ln.strip()]
arr = np.array([r[:2] for r in rows if len(r) >= 2])
curve, n_used, n_out = snap_to_grid(arr[:, 0] * k, arr[:, 1])
except gr.Error:
raise
except Exception as e:
raise gr.Error(f"Could not parse input: {e}")
return render(curve, k, note=f"{n_used} grid periods filled")
INTRO = (
"# Shear-wave velocity from a phase-velocity dispersion curve\n\n"
"Inverts a fundamental-mode Rayleigh **phase-velocity** curve (the "
"SASW/MASW observable) for a 70-layer 1-D Vs profile in one forward pass "
"— no initial model. Proposed physically-coded transformer "
"encoder–decoder (2.44 M params: period tokens in, depth-query tokens "
"out) trained on **OpenSWI-shallow** (22 M curve/profile pairs).")
SCALE_NOTE = (
"**Site scale:** the physics is scale-invariant — measured periods are "
"multiplied by k to enter the model's 0.2–10 s band, and output depths "
"are divided by k. Velocities are never rescaled (support "
"≈ 0.3–4.5 km/s at any scale). Examples are native-scale; k applies to "
"uploaded/pasted curves only. Picks are snapped to the nearest of the "
f"{len(PERIOD)} grid periods; gaps and band-limited curves are handled "
"natively.")
DETAILS = (
"- **Model:** physically-coded encoder–decoder (paper3): each dispersion "
"pick is a token carrying its physical period; each output depth is a query "
"token carrying its physical depth, reading the period tokens by masked "
"cross-attention — band-limited and gappy curves are handled natively "
"(no interpolation), and its learned attention reproduces the classical "
"λ/3 sensitivity rule (paper3, Fig. 7).\n"
"- **Checkpoint:** v4 finalist (valid masked MSE 0.0216 (km/s)²); test "
"accuracy 0.151 km/s full-profile RMSE / 6.0 % MAPE / R² 0.949 on 50 k "
"held-out samples; Long Beach field data 38 m/s MAE vs the tomographic "
"reference (5,297 real curves).\n"
"- **Scope:** trained on 0.2–10 s periods, 0–2.76 km depth, Vs ≈ 0.3–4.5 km/s "
"(OpenSWI-shallow). Curves outside this envelope — e.g. soft-soil sites with "
"Vs < 0.3 km/s — are out of distribution.\n"
"- **Site scale factor k:** the elastodynamic problem is scale-invariant, so "
"a high-frequency engineering curve is inverted by stretching its periods "
"×k into the training band and shrinking the output depths ÷k (e.g. k = 100 "
"→ 70 layers over 0.4–27.6 m at 0.4 m spacing). Velocities are never "
"rescaled — the ≈ 0.3–4.5 km/s support applies at every scale; see paper3 §6.2.")
with gr.Blocks(title="Vs from dispersion curve") as demo:
gr.Markdown(INTRO)
with gr.Row():
with gr.Column(scale=1):
preset = gr.Dropdown(K_PRESETS, value=K_PRESETS[0],
label="Scale factor k")
k_custom = gr.Number(value=100.0, minimum=1.0, maximum=10000.0,
label="Custom k (used when preset is "
"'Custom k')")
gr.Markdown(SCALE_NOTE)
with gr.Tab("Example from test data"):
ex = gr.Dropdown(EX_LABELS, value=EX_LABELS[0],
label="Example (native scale, k = 1)")
lo_p = gr.Slider(float(PERIOD.min()), float(PERIOD.max()),
value=float(PERIOD.min()),
label="Min period (s) — simulates a "
"band-limited survey")
hi_p = gr.Slider(float(PERIOD.min()), float(PERIOD.max()),
value=float(PERIOD.max()),
label="Max period (s)")
btn_ex = gr.Button("Invert example", variant="primary")
with gr.Tab("Upload CSV"):
gr.Markdown("First column: period (s) **or** frequency (Hz); "
"second column: phase velocity. A header row is "
"expected.")
up = gr.File(file_types=[".csv", ".txt"], label="CSV file")
freq_in = gr.Checkbox(False,
label="First column is frequency (Hz)")
unit = gr.Radio(["km/s", "m/s"], value="km/s",
label="Velocity unit")
btn_csv = gr.Button("Invert CSV", variant="primary")
with gr.Tab("Paste values"):
txt = gr.Textbox(DEFAULT_PASTE, lines=12,
label="One 'period_s, velocity_km_s' pair "
"per line")
btn_txt = gr.Button("Invert pasted curve", variant="primary")
with gr.Column(scale=2):
with gr.Row():
plot_in = gr.Plot(label="Input curve")
plot_out = gr.Plot(label="Predicted 1-D Vs profile")
summary = gr.Markdown()
dl = gr.File(label="Predicted profile (CSV)")
with gr.Accordion("Model & protocol details", open=False):
gr.Markdown(DETAILS)
outputs = [plot_in, plot_out, summary, dl]
btn_ex.click(invert_example, [ex, lo_p, hi_p], outputs)
btn_csv.click(invert_csv, [up, freq_in, unit, preset, k_custom], outputs)
btn_txt.click(invert_paste, [txt, preset, k_custom], outputs)
if __name__ == "__main__":
demo.launch()
|