A small Transformer, the same seismic question¶
Francisco Plaza-Vega · STATSEI14 · 13 October 2026
Does a more flexible sequence encoder improve the probability of at least one catalogue M ≥ 5 event in the next seven days, using 30 past days from the same Chilean region?
Eight-minute discussion: fixed question (2 min), read the attention model (2 min), paired evidence (2 min), scientific interpretation (2 min). Follow the prepared outputs during the session; running all six fits is optional. The complete coding exercise remains follow-up work.
We compare a 16-unit LSTM and one compact Transformer encoder block using three catalogue channels. The ETAS-inspired channel is omitted here: architecture changes while representation and task remain fixed. This does not reproduce a published Transformer study.
Scope: the 2021–2025 test results have already been inspected. This is an exploratory retrospective comparison, not a new blind test or operational forecasting. The frozen catalogue contains revised event records; historical availability is not reconstructed.
1. Follow prepared results or make a new run¶
The participant notebook defaults to prepared pilot results. Change RUN_TRAINING to True for a separate fixed-protocol run. Keep EVALUATE_TEST=True only for the unchanged original comparison. For new seeds or a manually changed architecture, set EVALUATE_TEST=False; all cells can still run in order. The solved notebook was executed in a fresh local CPU kernel with training enabled; its outputs identify that run.
Locally, open this notebook inside the extracted course pack. In Colab, upload course-pack.zip when requested. The smaller data-pack.zip lacks the reference features and pilot results. No live catalogue download occurs.
If imports fail, run this in a separate cell and restart:
%pip install tensorflow==2.19.1 keras==3.15.1 numpy==2.1.3 pandas==2.3.3 scikit-learn==1.9.1 matplotlib
The environment files record the tested local versions. Local CPU timing does not certify Colab performance.
RUN_TRAINING = True # True fits new models; False follows prepared pilot evidence.
SEEDS = [7, 17, 27] # Predeclare three seeds before looking at their results.
EVALUATE_TEST = True # False for every extension; True only for the original fixed rerun.
REFERENCE_ARCHITECTURE = True # Set False before manually changing an architecture.
EXTENSION_NOTE = "" # Describe any manual architecture change, such as omitting positions.
import os
os.environ["CUDA_VISIBLE_DEVICES"] = "-1"
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2"
os.environ["TF_NUM_INTRAOP_THREADS"] = "2"
os.environ["TF_NUM_INTEROP_THREADS"] = "2"
os.environ["OMP_NUM_THREADS"] = "2"
from pathlib import Path
import datetime, hashlib, json, platform, time, uuid, zipfile
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import sklearn
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import brier_score_loss, log_loss
import tensorflow as tf
from tensorflow import keras
from IPython.display import display
NOTEBOOK_STARTED = time.perf_counter()
try:
tf.config.threading.set_intra_op_parallelism_threads(2)
tf.config.threading.set_inter_op_parallelism_threads(2)
except RuntimeError:
print("Restart the runtime to apply reference thread settings.")
print("Mode:", "NEW FIXED-PROTOCOL RUN" if RUN_TRAINING else "PREPARED PILOT RESULTS")
print("TensorFlow:", tf.__version__, "Keras:", keras.__version__, "Python:", platform.python_version())
ROOT = None
required_relative = ["config/course.json", "results/reference-run/features_raw.npz",
"results/reference-run/window_metadata.csv", "results/reference-run/normalization.json",
"results/transformer-pilot/protocol.json", "results/transformer-pilot/metrics.csv",
"results/transformer-pilot/fits.csv", "results/transformer-pilot/predictions.csv",
"results/transformer-pilot/runtime.json"]
for candidate in [Path.cwd(), *Path.cwd().parents]:
if all((candidate / name).is_file() for name in required_relative):
ROOT = candidate
break
if ROOT is None:
try:
from google.colab import files
except ImportError:
raise FileNotFoundError("Open this notebook inside the complete extracted course-pack.zip.")
print("Upload course-pack.zip, including reference data and prepared pilot results.")
uploaded = files.upload()
archives = [name for name in uploaded if name.lower().endswith(".zip")]
if len(archives) != 1: raise ValueError("Upload exactly one course-pack.zip.")
destination = (Path.cwd() / "transformer-course-data").resolve()
destination.mkdir(exist_ok=True)
with zipfile.ZipFile(archives[0]) as archive:
for member in archive.infolist():
if not (destination / member.filename).resolve().is_relative_to(destination):
raise ValueError("Unsafe ZIP path.")
archive.extractall(destination)
candidates = [p.parents[1] for p in destination.rglob("config/course.json")]
candidates = [p for p in candidates if all((p / name).is_file() for name in required_relative)]
if len(candidates) != 1: raise ValueError("The complete pack must contain one course folder.")
ROOT = candidates[0]
PILOT = ROOT / "results/transformer-pilot"
protocol = json.loads((PILOT / "protocol.json").read_text(encoding="utf-8-sig"))
config = json.loads((ROOT / "config/course.json").read_text(encoding="utf-8-sig"))
PILOT_SEEDS = [7, 17, 27] # Immutable provenance of the prepared pilot.
FEATURES = ["log_count", "max_magnitude_excess", "has_event"]
assert protocol["seeds"] == PILOT_SEEDS and protocol["features"] == FEATURES
assert len(SEEDS) == 3 and len(set(SEEDS)) == 3 and all(isinstance(seed, int) for seed in SEEDS)
if SEEDS != PILOT_SEEDS or not REFERENCE_ARCHITECTURE:
assert RUN_TRAINING and not EVALUATE_TEST, "Extensions require RUN_TRAINING=True and EVALUATE_TEST=False."
if not REFERENCE_ARCHITECTURE:
assert EXTENSION_NOTE.strip(), "Describe the manual architecture change in EXTENSION_NOTE."
if not RUN_TRAINING:
assert SEEDS == PILOT_SEEDS and REFERENCE_ARCHITECTURE, "Prepared mode uses the original pilot configuration."
allowed_splits = ["validation", "test"] if EVALUATE_TEST else ["validation"]
pilot_metrics = pd.read_csv(PILOT / "metrics.csv").query("split in @allowed_splits").copy()
pilot_fits = pd.read_csv(PILOT / "fits.csv")
pilot_predictions = pd.read_csv(PILOT / "predictions.csv").query("split in @allowed_splits").copy()
pilot_runtime = json.loads((PILOT / "runtime.json").read_text(encoding="utf-8-sig"))
run_protocol = json.loads(json.dumps(protocol))
run_protocol.update({
"purpose": "Demo rerun or declared validation-only extension",
"seeds": SEEDS, "evaluate_test": EVALUATE_TEST,
"reference_architecture_declared": REFERENCE_ARCHITECTURE,
"extension_note": EXTENSION_NOTE or ("New predeclared seeds; unchanged architecture." if SEEDS != PILOT_SEEDS else "Original architecture and seeds."),
"source_pilot_protocol_sha256": hashlib.sha256((PILOT / "protocol.json").read_bytes()).hexdigest(),
"evaluation_sequence": "Fit all candidates on train/validation; freeze artifacts; " + ("evaluate previously inspected test once." if EVALUATE_TEST else "validation only; no test predictions or test metrics."),
})
if not REFERENCE_ARCHITECTURE:
run_protocol["original_transformer_reference_specification"] = run_protocol.pop("transformer")
run_protocol["actual_architecture"] = "See saved model configuration JSON files and EXTENSION_NOTE."
run_protocol["interpretation"] = "Exploratory retrospective; " + ("previously inspected test." if EVALUATE_TEST else "training and validation only; test not evaluated.")
assert config["lookback_days"] == 30 and config["horizon_days"] == 7
assert config["input_magnitude"] == 4.5 and config["target_magnitude"] == 5
for relative, expected in protocol["input_hashes"].items():
assert hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() == expected, f"Changed input: {relative}"
pilot_hashes_before = {p.name:hashlib.sha256(p.read_bytes()).hexdigest() for p in PILOT.iterdir() if p.is_file()}
print("Course folder:", ROOT)
print("Frozen inputs verified; original pilot completed:", pilot_runtime["completed_utc"])
2. The comparison fixed before fitting¶
| Fixed choice | Setting |
|---|---|
| Input | Log count, maximum magnitude excess and event presence for 30 days |
| Region | 28–34°S, 69–74°W; input catalogue M ≥ 4.5 |
| Target | At least one catalogue M ≥ 5 event in the following seven days |
| Origins | Mondays; target weeks crossing partition boundaries excluded |
| Train / validation / test | 2001–2016 / 2017–2020 / 2021–2025 |
| Training | Adam 0.001, binary cross-entropy, batch 64, maximum 20 epochs, no shuffling |
| Stopping | Validation loss, patience 3, restore best weights |
| Seeds | 7, 17 and 27 for both architectures |
There are 834 training weeks with 197 positives. More parameters do not automatically provide useful information from this sample.
The table below is existing pilot evidence, separate from a new run. Variation across seeds describes optimization variability, not uncertainty about future generalization.
display(pilot_metrics.groupby(["model", "split"])[["brier", "log_loss"]].agg(["mean", "std"]).round(6))
display(pilot_fits[["model", "seed", "parameter_count", "epochs", "best_epoch", "fit_seconds"]].round(3))
print("Original pilot fit seconds:", round(pilot_runtime["total_fit_seconds"], 3))
print("Original pilot Colab verified:", pilot_runtime["colab_execution_verified"])
3. Identical windows and training-only scaling¶
The core practical constructs the supplied windows from the frozen catalogue. This notebook checks their fingerprints and boundaries, then takes only the three catalogue channels. The original pilot also reconstructed features and labels independently from the event records.
X is weeks × 30 days × 3 features. Inputs are [origin − 30 days, origin); targets are [origin, origin + 7 days). Adjacent histories can overlap even though target weeks do not. Scaling uses training windows only. Event time does not certify the historical availability of revised records.
with np.load(ROOT / "results/reference-run/features_raw.npz", allow_pickle=False) as archive:
assert archive["feature_names"].tolist() == FEATURES + ["log_etas_score"]
X_raw = archive["X_raw"][:, :, :3].copy()
windows = pd.read_csv(ROOT / "results/reference-run/window_metadata.csv")
for column in ["origin", "input_start", "input_end", "target_end"]:
windows[column] = pd.to_datetime(windows[column], utc=True)
y = windows.y.to_numpy(dtype="int32")
assert X_raw.shape == (len(windows), 30, 3) and np.isfinite(X_raw).all()
assert windows.origin.is_unique and windows.origin.is_monotonic_increasing
assert (windows.input_end == windows.origin).all()
assert ((windows.origin - windows.input_start).dt.days == 30).all()
assert ((windows.target_end - windows.origin).dt.days == 7).all()
assert (windows.origin.dt.dayofweek == 0).all()
assert (windows.target_end.iloc[:-1].to_numpy() <= windows.origin.iloc[1:].to_numpy()).all()
bounds = {name:pd.Timestamp(config[name], tz="UTC") for name in ["train_start", "validation_start", "test_start", "catalog_end"]}
masks = {
"train": (windows.origin.ge(bounds["train_start"]) & windows.origin.lt(bounds["validation_start"]) & windows.target_end.le(bounds["validation_start"])).to_numpy(),
"validation": (windows.origin.ge(bounds["validation_start"]) & windows.origin.lt(bounds["test_start"]) & windows.target_end.le(bounds["test_start"])).to_numpy(),
"test": (windows.origin.ge(bounds["test_start"]) & windows.origin.lt(bounds["catalog_end"]) & windows.target_end.le(bounds["catalog_end"])).to_numpy(),
}
expected_sizes = {"train":(834,197), "validation":(208,52), "test":(260,54)}
assert (sum(mask.astype(int) for mask in masks.values()) <= 1).all()
for split, mask in masks.items():
assert np.array_equal(mask, windows.split.eq(split).to_numpy())
if split != "test" or EVALUATE_TEST:
assert (int(mask.sum()), int(y[mask].sum())) == expected_sizes[split]
train, validation, test = masks["train"], masks["validation"], masks["test"]
scaler = StandardScaler().fit(X_raw[train].reshape(-1, 3))
reference_scaler = json.loads((ROOT / "results/reference-run/normalization.json").read_text())
np.testing.assert_allclose(scaler.mean_, reference_scaler["mean"][:3], rtol=1e-10, atol=1e-10)
np.testing.assert_allclose(scaler.scale_, reference_scaler["scale"][:3], rtol=1e-10, atol=1e-10)
X = scaler.transform(X_raw.reshape(-1, 3)).reshape(X_raw.shape).astype("float32")
print("Input shape:", X.shape, "training channel means:", scaler.mean_.round(4))
display(windows.loc[windows.split.isin(["train"] + allowed_splits)].groupby("split").agg(weeks=("y", "size"), positives=("y", "sum")))
4. Two ways to combine the same past¶
The LSTM carries a learned state through the days. The Transformer lets each day combine information from other days in the same window.
The compact model uses projection to 16 features, fixed day positions, two attention heads, residual connections and normalization, a feed-forward block, pooling and a sigmoid output. A causal attention mask is unnecessary because all input days precede the forecast origin. Position encoding preserves day order.
This position layer has no learned parameters. It is registered for serialization; run its definition before loading a saved model in another process. Attention weights alone are not evidence of a causal seismic mechanism.
@keras.utils.register_keras_serializable(package="StatseiDemo")
class SinusoidalPosition(keras.layers.Layer):
def __init__(self, length=30, width=16, **kwargs):
super().__init__(**kwargs)
self.length, self.width = length, width
positions = np.arange(length, dtype=np.float32)[:, None]
frequencies = np.exp(np.arange(0, width, 2, dtype=np.float32) * (-np.log(10000.0) / width))
encoding = np.zeros((length, width), dtype=np.float32)
encoding[:, 0::2] = np.sin(positions * frequencies)
encoding[:, 1::2] = np.cos(positions * frequencies)
self.encoding = encoding[None, :, :]
def call(self, inputs):
return inputs + tf.cast(tf.convert_to_tensor(self.encoding), inputs.dtype)
def get_config(self):
return {**super().get_config(), "length":self.length, "width":self.width}
5. Read the fitting loop, or run it after the session¶
Extensions: predeclare three new SEEDS with RUN_TRAINING=True and EVALUATE_TEST=False. For a manual architecture change, also set REFERENCE_ARCHITECTURE=False and describe it in EXTENSION_NOTE before editing the model code. The saved model configuration records the actual architecture. This is not an architecture search.
Input, layers, compile, fit and predict remain visible. The loops pair seeds and change architecture. Order alternates to reduce a systematic timing advantage from running second. All six candidates are retained; none is selected using test results.
The constant reference uses training prevalence. Logistic regression uses nine mean/max/last summaries of the same channels. Normalization and fitting remain restricted to training.
New outputs go to a timestamped folder under results/transformer-demo-run. The original pilot and core practical remain unchanged. If training is unavailable, leave RUN_TRAINING = False.
models, histories, validation_probabilities = {}, {}, {}
fit_rows = []
new_run_output = None
freeze = None
if RUN_TRAINING:
run_name = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid.uuid4().hex[:6]
new_run_output = ROOT / "results/transformer-demo-run" / run_name
(new_run_output / "models").mkdir(parents=True, exist_ok=False)
(new_run_output / "histories").mkdir()
base_rate = float(y[train].mean())
summaries = np.concatenate([X_raw.mean(axis=1), X_raw.max(axis=1), X_raw[:, -1, :]], axis=1)
summary_scaler = StandardScaler().fit(summaries[train])
Z = summary_scaler.transform(summaries)
logistic = LogisticRegression(C=1.0, max_iter=1000, random_state=20261013)
logistic.fit(Z[train], y[train])
validation_probabilities[("Training frequency", -1)] = np.full(int(validation.sum()), base_rate)
validation_probabilities[("Logistic catalogue", -1)] = logistic.predict_proba(Z[validation])[:, 1]
for seed_index, seed in enumerate(SEEDS):
order = ["LSTM catalogue", "Transformer compact"] if seed_index % 2 == 0 else ["Transformer compact", "LSTM catalogue"]
for model_name in order:
keras.backend.clear_session()
keras.utils.set_random_seed(seed)
inputs = keras.Input(shape=(30, 3), name="past_30_days")
if model_name == "LSTM catalogue":
sequence_summary = keras.layers.LSTM(16, name="lstm")(inputs)
else:
x = keras.layers.Dense(16, name="daily_projection")(inputs)
x = SinusoidalPosition(name="day_position")(x)
attention = keras.layers.MultiHeadAttention(
num_heads=2, key_dim=8, dropout=0.0, name="self_attention"
)(x, x)
x = keras.layers.LayerNormalization(epsilon=1e-6, name="attention_norm")(
keras.layers.Add()([x, attention])
)
feed_forward = keras.layers.Dense(32, activation="relu", name="ff_expand")(x)
feed_forward = keras.layers.Dense(16, name="ff_project")(feed_forward)
x = keras.layers.LayerNormalization(epsilon=1e-6, name="ff_norm")(
keras.layers.Add()([x, feed_forward])
)
sequence_summary = keras.layers.GlobalAveragePooling1D(name="past_window_pooling")(x)
output = keras.layers.Dense(1, activation="sigmoid", name="next_week_probability")(sequence_summary)
model = keras.Model(inputs, output, name=model_name.lower().replace(" ", "_"))
model.compile(optimizer=keras.optimizers.Adam(learning_rate=0.001), loss="binary_crossentropy")
early_stopping = keras.callbacks.EarlyStopping(monitor="val_loss", patience=3, restore_best_weights=True)
fit_started = time.perf_counter()
history = model.fit(
X[train], y[train], validation_data=(X[validation], y[validation]),
epochs=20, batch_size=64, callbacks=[early_stopping], shuffle=False, verbose=0,
)
fit_seconds = time.perf_counter() - fit_started
assert np.isfinite(np.asarray(list(history.history.values()))).all()
probability = model.predict(X[validation], verbose=0).ravel()
assert np.isfinite(probability).all() and ((0 <= probability) & (probability <= 1)).all()
slug = model_name.lower().replace(" ", "_") + f"_seed_{seed}"
model_path = new_run_output / "models" / f"{slug}.keras"
model.save(model_path)
model_path.with_suffix(".json").write_text(model.to_json(), encoding="utf-8")
reloaded = keras.models.load_model(model_path)
np.testing.assert_allclose(probability, reloaded.predict(X[validation], verbose=0).ravel(), rtol=1e-6, atol=1e-7)
models[(model_name, seed)] = model_path
histories[(model_name, seed)] = pd.DataFrame(history.history)
histories[(model_name, seed)].rename_axis("epoch_index").to_csv(new_run_output / "histories" / f"{slug}.csv")
validation_probabilities[(model_name, seed)] = probability
fit_rows.append({"model":model_name, "seed":seed, "parameter_count":model.count_params(),
"epochs":len(history.history["loss"]), "best_epoch":int(np.argmin(history.history["val_loss"])) + 1,
"fit_seconds":fit_seconds, "model_file":str(model_path.relative_to(new_run_output))})
print(model_name, "seed", seed, "epochs", fit_rows[-1]["epochs"],
"validation Brier", round(float(brier_score_loss(y[validation], probability)), 6))
fits = pd.DataFrame(fit_rows)
print("Six fits complete. New test predictions have not yet been computed.")
else:
fits = pilot_fits.copy()
print("Prepared mode: no models fitted and no new test predictions computed.")
display(fits[["model", "seed", "parameter_count", "epochs", "best_epoch", "fit_seconds"]].round(3))
6. Freeze, then evaluate the previously inspected test¶
A new run records all six model fingerprints, validation scores and the actual protocol. With EVALUATE_TEST=False, evaluation ends on validation: no test predictions or test metrics are computed or displayed. With True, the unchanged original comparison then computes test predictions. This keeps a changed candidate from being presented as the original tested candidate. It does not make the previously inspected test blind again.
Retain all seeds. A mean of per-seed scores is not the performance of an ensemble of their probabilities.
if RUN_TRAINING:
validation_rows = []
for (name, seed), probability in validation_probabilities.items():
validation_rows.append({"model":name, "seed":seed, "split":"validation",
"brier":float(brier_score_loss(y[validation], probability)),
"log_loss":float(log_loss(y[validation], probability, labels=[0,1])),
"n":int(validation.sum()), "positives":int(y[validation].sum()), "positive_rate":float(y[validation].mean())})
pd.DataFrame(validation_rows).to_csv(new_run_output / "validation_metrics.csv", index=False)
freeze = {
"frozen_utc":datetime.datetime.now(datetime.timezone.utc).isoformat(),
"source_pilot_protocol_sha256":run_protocol["source_pilot_protocol_sha256"],
"actual_run_protocol":run_protocol,
"seeds":SEEDS, "features":FEATURES, "evaluate_test":EVALUATE_TEST,
"models":{str(p.relative_to(new_run_output)):hashlib.sha256(p.read_bytes()).hexdigest() for p in models.values()},
"validation_metrics_sha256":hashlib.sha256((new_run_output / "validation_metrics.csv").read_bytes()).hexdigest(),
"selection":"All six predeclared fits retained; no test-based selection or tuning.",
"test_scope":"Previously inspected test; exploratory retrospective." if EVALUATE_TEST else "Not evaluated: validation-only extension.",
}
(new_run_output / "fit_freeze.json").write_text(json.dumps(freeze, indent=2) + "\n", encoding="utf-8")
evaluation_probabilities = {"validation": validation_probabilities}
if EVALUATE_TEST:
test_probabilities = {
("Training frequency", -1):np.full(int(test.sum()), base_rate),
("Logistic catalogue", -1):logistic.predict_proba(Z[test])[:, 1],
}
for key, model_path in models.items():
frozen_model = keras.models.load_model(model_path)
test_probabilities[key] = frozen_model.predict(X[test], verbose=0).ravel()
evaluation_probabilities["test"] = test_probabilities
metric_rows, prediction_frames = [], []
for split, probabilities in evaluation_probabilities.items():
mask = masks[split]
for (name, seed), probability in probabilities.items():
assert len(probability) == int(mask.sum()) and np.isfinite(probability).all()
assert ((probability >= 0) & (probability <= 1)).all()
metric_rows.append({"model":name, "seed":seed, "split":split,
"brier":float(brier_score_loss(y[mask], probability)),
"log_loss":float(log_loss(y[mask], probability, labels=[0,1])),
"n":int(mask.sum()), "positives":int(y[mask].sum()), "positive_rate":float(y[mask].mean())})
prediction_frames.append(pd.DataFrame({"model":name, "seed":seed, "split":split,
"origin":windows.loc[mask,"origin"].to_numpy(), "y_true":y[mask], "probability":probability}))
metrics = pd.DataFrame(metric_rows)
predictions = pd.concat(prediction_frames, ignore_index=True)
evidence_label = "New fixed-protocol demo run" if EVALUATE_TEST else "New validation-only run; see declared protocol"
else:
metrics, predictions = pilot_metrics.copy(), pilot_predictions.copy()
evidence_label = "Original prepared pilot" + ("" if EVALUATE_TEST else "; validation only")
assert not predictions.duplicated(["model", "seed", "split", "origin"]).any()
assert predictions.groupby(["split", "origin"]).y_true.nunique().eq(1).all()
print(evidence_label)
display(metrics.round(6))
7. Read errors and probability forecasts¶
Brier score is squared probability error; log loss penalizes confident incorrect probabilities. Smaller is better. Pair models by seed and evaluation period instead of selecting each model's best seed.
Calibration uses the same ten fixed bins for every model and seed. Empty bins stay in the table with zero counts and missing means; only occupied bins are plotted. The table shows weeks per bin: a handful of high-probability weeks cannot establish precise calibration. The distribution panel reveals whether predictions span a narrow range.
display(metrics.groupby(["model", "split"])[["brier", "log_loss"]].agg(["mean", "std"]).round(6))
paired = metrics.loc[metrics.seed.isin(SEEDS)].pivot(index=["split", "seed"], columns="model", values=["brier", "log_loss"])
differences = pd.DataFrame({
"Transformer minus LSTM: Brier":paired["brier"]["Transformer compact"] - paired["brier"]["LSTM catalogue"],
"Transformer minus LSTM: log loss":paired["log_loss"]["Transformer compact"] - paired["log_loss"]["LSTM catalogue"],
})
display(differences.round(6))
print("Positive values mean higher Transformer error in that pair.")
print("Seed variation describes these fits, not generalization uncertainty.")
BIN_EDGES = np.asarray(protocol["calibration_bin_edges"], dtype=float)
calibration_rows = []
for (name, seed, split), group in predictions.loc[predictions.seed.isin(SEEDS)].groupby(["model", "seed", "split"]):
grouped = group.assign(bin=pd.cut(group.probability, bins=BIN_EDGES, include_lowest=True))
table = grouped.groupby("bin", observed=False).agg(
n=("y_true", "size"), mean_probability=("probability", "mean"), observed_fraction=("y_true", "mean")
).reset_index()
table["bin"] = table["bin"].astype(str)
table["model"], table["seed"], table["split"] = name, seed, split
calibration_rows.append(table)
calibration = pd.concat(calibration_rows, ignore_index=True)
colors = {"LSTM catalogue":"#087f78", "Transformer compact":"#d27b25"}
plot_split = "test" if EVALUATE_TEST else "validation"
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
axes[0].plot([0,1], [0,1], "--", color="gray", linewidth=1)
for (name, seed), group in calibration.loc[calibration.split.eq(plot_split)].groupby(["model", "seed"]):
occupied_bins = group.loc[group.n.gt(0)]
axes[0].plot(occupied_bins.mean_probability, occupied_bins.observed_fraction, "o-", alpha=0.65,
color=colors[name], label=f"{name}; seed {seed}")
probability = predictions.loc[predictions.model.eq(name) & predictions.seed.eq(seed) & predictions.split.eq(plot_split), "probability"]
axes[1].hist(probability, bins=BIN_EDGES, histtype="step", linewidth=1.5, alpha=0.7,
color=colors[name], label=f"{name}; seed {seed}")
axes[0].set(xlabel="Mean predicted probability", ylabel="Observed positive fraction",
xlim=(0,1), ylim=(0,1), title=f"{plot_split.title()}: fixed bins")
axes[1].set(xlabel="Predicted probability", ylabel="Weeks per run", title="Probability distribution", xlim=(0,1))
axes[1].legend(fontsize=7)
fig.suptitle(evidence_label)
plt.tight_layout()
plt.show()
display(calibration.loc[calibration.split.eq(plot_split), ["model", "seed", "bin", "n", "mean_probability", "observed_fraction"]].round(4))
8. What does stopping tell us?¶
In the original pilot, the Transformer stopped after 5–9 epochs; restored best epochs were 2, 3 and 6. The LSTM's best epoch was 20 in all three runs, reaching the fixed budget. This describes the chosen configuration and stopping rule. It neither establishes faster Transformer convergence nor diagnoses a unique cause of error.
A shorter fit also used fewer epochs. Distinguish runtime, optimization behavior and predictive evidence. Do not extend training based on these test results while calling the same test untouched.
if RUN_TRAINING:
fig, axes = plt.subplots(1, 2, figsize=(11, 3.5))
for (name, seed), history in histories.items():
epochs = np.arange(1, len(history) + 1)
for axis, metric in zip(axes, ["loss", "val_loss"]):
axis.plot(epochs, history[metric], color=colors[name], alpha=0.65, label=f"{name}; {seed}")
axes[0].set(title="Training loss", xlabel="Epoch", ylabel="Binary cross-entropy")
axes[1].set(title="Validation loss", xlabel="Epoch", ylabel="Binary cross-entropy")
axes[1].legend(fontsize=7)
plt.tight_layout()
plt.show()
else:
print("Prepared mode: stopping evidence comes from the original pilot summaries.")
display(fits[["model", "seed", "epochs", "best_epoch", "fit_seconds"]].round(3))
9. Keep new evidence separate¶
A new run exports metrics, predictions, calibration, normalization, models, histories, protocol and runtime information into its own folder. The frozen pilot stays unchanged. Differences across environments are possible; we measure them rather than replacing the reference.
if RUN_TRAINING:
metrics.to_csv(new_run_output / "metrics.csv", index=False)
predictions.to_csv(new_run_output / "predictions.csv", index=False)
fits.to_csv(new_run_output / "fits.csv", index=False)
calibration.to_csv(new_run_output / "calibration.csv", index=False)
differences.to_csv(new_run_output / "paired_differences.csv")
windows.loc[windows.split.isin(["train"] + allowed_splits)].to_csv(new_run_output / "window_metadata.csv", index=False)
normalization = {"fitted_split":"train", "features":FEATURES, "mean":scaler.mean_.tolist(), "scale":scaler.scale_.tolist()}
(new_run_output / "normalization.json").write_text(json.dumps(normalization, indent=2) + "\n", encoding="utf-8")
(new_run_output / "protocol.json").write_text(json.dumps(run_protocol, indent=2) + "\n", encoding="utf-8")
merged = metrics.merge(pilot_metrics, on=["model", "seed", "split"], suffixes=("_new", "_pilot"), validate="one_to_one")
maximum_difference = float(np.max(np.abs(merged[["brier_new", "log_loss_new"]].to_numpy() - merged[["brier_pilot", "log_loss_pilot"]].to_numpy()))) if len(merged) else None
runtime = {
"completed_utc":datetime.datetime.now(datetime.timezone.utc).isoformat(), "completed":True,
"python":platform.python_version(), "tensorflow":tf.__version__, "keras":keras.__version__,
"numpy":np.__version__, "pandas":pd.__version__, "scikit_learn":sklearn.__version__,
"platform":platform.platform(), "device":"CPU", "configured_threads":2,
"fit_seconds":float(fits.fit_seconds.sum()),
"notebook_seconds_since_imports":time.perf_counter() - NOTEBOOK_STARTED,
"max_abs_metric_difference_from_pilot":maximum_difference,
"scope":run_protocol["interpretation"], "seeds":SEEDS, "evaluate_test":EVALUATE_TEST,
"reference_architecture_declared":REFERENCE_ARCHITECTURE,
"extension_note":run_protocol["extension_note"], "pilot_comparison_matched_rows":len(merged),
"colab_execution_verified":False, "runtime_note":"This machine only; no separate Colab execution certified.",
}
(new_run_output / "runtime.json").write_text(json.dumps(runtime, indent=2) + "\n", encoding="utf-8")
print("New output folder:", new_run_output)
print("Total fit seconds:", round(runtime["fit_seconds"], 3))
print("Matched pilot rows:", len(merged), "; maximum metric difference:", maximum_difference)
if SEEDS != PILOT_SEEDS:
print("New seeds: only shared baseline rows match the original pilot; no missing-seed scores inferred.")
else:
runtime = pilot_runtime
print("Prepared pilot mode wrote no run outputs.")
pilot_hashes_after = {p.name:hashlib.sha256(p.read_bytes()).hexdigest() for p in PILOT.iterdir() if p.is_file()}
assert pilot_hashes_before == pilot_hashes_after, "Prepared pilot files changed."
print("Original prepared pilot files are unchanged.")
Discuss the result, not the model name¶
- Did architectural flexibility improve probability errors against the LSTM and simple references?
- What information do daily regional summaries preserve or discard? Could another question require another representation?
- What do seed variation, stopping and calibration bin counts leave uncertain?
- What additional data and untouched evaluation would support a stronger claim?
The prepared pilot's compact Transformer did not improve probability errors. This is a useful result: flexibility must match the information and question available. It concerns one small architecture, dataset and protocol, not a ranking of model families. Inputs are catalogue summaries, not raw waveforms or fine spatial grids.
The core practical's ETAS-inspired channel explores a different decision: changing the representation. A structural ETAS–neural model would need a fitted intensity and an appropriate forecast/evaluation formulation; this notebook does not implement it.
Continue with the homework guide. Sources: Keras attention layer, Keras time-series Transformer example, and Farfán, Nicolis & Peralta (2025), a different spatial-grid task.