6.3.11. Reproducible experiment configuration#
Every other package in Architecture roadmap’s map produces evidence: a
geological realization, a forward response, a fitted normalizer, a
loss value. None of that evidence answers a simpler question a
reviewer always asks first — what exactly produced this result, and
against what threshold was it judged acceptable?
pycsamt.ai.experiments exists to answer that question
before a run starts, not by reconstructing it afterward from
scattered scripts and a remembered epoch count. Its scope is
deliberately narrow: it describes what an experiment is — a pinned
dataset, a seed lineage, a configuration, a set of acceptance
criteria — without importing a machine-learning framework or
constructing a Maxwell solver. A config object here is a record, not
a runner.
6.3.11.1. Deriving stable, order-independent seeds#
A single global seed=42 sounds reproducible until two subsystems
happen to draw from the same generator in a different order between
runs, or a training seed and a data-shuffling seed accidentally
coincide.
SeedPlan replaces that with
one root seed and a namespace, from which every named subsystem
derives its own child seed deterministically:
where \(n\) is the namespace, \(r\) the root seed, \(\ell\) the label, and \(\mathbin\Vert\) byte-string concatenation with a null separator between fields. Equation (1) is a pure function of its three inputs, which is exactly what makes it useful: the same label always derives the same seed from the same plan, regardless of how many other labels were requested first or in what order.
The 32-bit result is compatible with NumPy and common learning frameworks, but it is not a mathematical guarantee of uniqueness. If \(k\) distinct labels behave like independent hash inputs, the birthday approximation gives
For 100 labels this is about \(1.15\times10^{-6}\); for 1,000 labels it
is about \(1.16\times10^{-4}\). Ordinary experiments are therefore very
unlikely to collide, but a large sweep should derive all labels once with
derive_many() and assert that
the returned values are unique. The namespace is a domain separator, not
extra entropy: changing it intentionally creates a different seed lineage.
>>> from pycsamt.ai.experiments.config import SeedPlan
>>> plan = SeedPlan(42, namespace="willy-l18")
>>> plan.derive("geology") == plan.derive("geology")
True
>>> plan.derive("geology") == plan.derive("network")
False
>>> plan.derive("network")
2959107669
>>> children = plan.derive_many(["geology", "network", "shuffle"])
>>> len(children) == len(set(children.values()))
True
This is not a hypothetical convenience: 2-D Maxwell training-data generation’s
generate_2d_maxwell_dataset()
builds one SeedPlan from a dataset configuration’s root seed
and derives a labeled f"{realization_id}/correlation" and
f"{realization_id}/field" seed for every realization it
generates, so regenerating realization 47 alone, on a different
machine, still draws the exact field and correlation-length values
realization 47 drew the first time — no realization’s randomness
depends on how many realizations were requested before it.
3-D Maxwell training-data generation’s generator follows the identical seed-derivation
pattern for its own correlation/field/split seeds.
6.3.11.2. Pinning a dataset by hash, not by path#
A file path is not a stable reference: willy_l18.npz can be
silently regenerated, moved, or overwritten while an experiment
record still points at its old location and nobody notices until
results stop reproducing.
DatasetReference pins an
experiment to the SHA-256 digests Canonical data contracts already
produces for exactly this purpose — a
DatasetManifest’s
manifest_hash, a
RealizationSplit’s split_hash,
and, when the experiment normalizes data, a
ComplexZScore’s
state_hash — rather than to a path a caller might record as an
afterthought:
>>> from pycsamt.emtools._core import ensure_sites
>>> from pycsamt.ai.domain_gap import survey_data_from_sites
>>> from pycsamt.ai.data.normalization import ComplexZScore
>>> from pycsamt.ai.data.splits import RealizationSplit
>>> from pycsamt.ai.data.manifest import DatasetManifest
>>> from pycsamt.ai.experiments.config import DatasetReference
>>> sites = ensure_sites(
... "data/AMT/WILLY_data/L18PLT", recursive=True, verbose=0
... )
>>> field = survey_data_from_sites(sites, recursive=False, verbose=0)
>>> state = ComplexZScore.fit(field)
>>> split = RealizationSplit(("willy-l18",), (), ())
>>> manifest = DatasetManifest(
... dataset_id="willy-l18-zscore-v1",
... generator="pycsamt.ai.data.normalization.ComplexZScore",
... generator_version="1.0",
... configuration={"weighting": "uniform", "eps": 1e-8},
... split=split,
... sample_count=field.n_stations,
... )
>>> dataset_reference = DatasetReference(
... "willy-l18-zscore-v1",
... manifest.manifest_hash,
... split.split_hash,
... normalization_hash=state.state_hash,
... )
>>> dataset_reference.manifest_hash == manifest.manifest_hash
True
Every digest above comes from the exact same L18 objects Canonical data contracts builds and checks: this is not a parallel bookkeeping scheme, it is those hashes read back into a record that names which dataset, which split, and which fitted normalizer an experiment actually used.
These digests provide content integrity, not storage or automatic
verification. DatasetReference checks that each supplied digest has
the syntax of a SHA-256 value; it does not open manifest_uri and prove
that the object currently stored there has that digest. At the execution
boundary, load the manifest, split, and normalizer, recompute their hashes,
and refuse the run if any value differs from the reference. A URI answers
“where might I retrieve it?” while the digest answers “is this the object I
approved?” Neither substitutes for the other.
6.3.11.3. Fixing acceptance criteria before looking at results#
Choosing a passing threshold after seeing the score is not
validation, it is curve fitting to one run.
AcceptanceCriterion exists to
make that impossible by construction: a metric name, a comparison
operator, and a threshold, frozen into the experiment record before
any metric is observed. A held-out RMS misfit threshold is
the most natural example — the same normalized residual concept
Recovery, residual, and OOD diagnostics computes and this page’s criteria merely
give a name and a number to:
>>> from pycsamt.ai.experiments.config import AcceptanceCriterion
>>> nrms_ok = AcceptanceCriterion(
... "test.impedance_nrms", "<=", 2.0, "response fit vs. observed"
... )
>>> nrms_ok.evaluate(1.6)
True
>>> nrms_ok.evaluate(2.4)
False
A single criterion is only half the picture; a real experiment
usually predeclares several, and
evaluate_gate()
checks all of them together as one
GateEvaluation. Crucially, a
metric an experiment forgot to compute is not silently skipped — it
counts as an incomplete, failing gate rather than a passing one by
omission:
With criteria \(C_j(m_j)\) evaluated on observed metrics \(m_j\), the final decision is the logical conjunction
where \(\mathcal M\) is the set of missing required metrics. Thus one
failure or one missing value makes \(G\) false. NaN and infinity are
rejected rather than compared, and duplicate metric names are forbidden.
Avoid == for floating-point scientific metrics unless exact equality is
truly meaningful; a tolerance expressed with <= or >= is normally
the defensible choice.
>>> criteria = [
... AcceptanceCriterion("test.impedance_nrms", "<=", 2.0),
... AcceptanceCriterion("test.recovery_rmse", "<=", 0.5),
... ]
>>> from pycsamt.ai.experiments.config import ExperimentConfig, SeedPlan
>>> config = ExperimentConfig(
... "willy-l18-mt2d-v1",
... "learning_2d",
... dataset_reference,
... SeedPlan(42, namespace="willy-l18"),
... model={"architecture": "unet", "lambda_x": 0.1, "lambda_z": 0.1},
... training={"epochs": 100, "batch_size": 8},
... physics={"solver": "mt2d", "components": ["zxy"]},
... acceptance=criteria,
... )
>>> full = config.evaluate_gate(
... {"test.impedance_nrms": 1.6, "test.recovery_rmse": 0.42}
... )
>>> full.passed
True
>>> partial = config.evaluate_gate({"test.impedance_nrms": 1.6})
>>> partial.passed, partial.complete, partial.missing
(False, False, ('test.recovery_rmse',))
partial never claims a pass it cannot support: with
test.recovery_rmse unmeasured, complete is False and the
gate fails, even though the one metric it did see would have passed
on its own. That asymmetry — silence must fail, never pass — is the
whole point of predeclaring criteria in the first place.
For a genuine 3-D experiment, response fit and model recovery are necessary but not sufficient gates. Predeclare a solver benchmark tolerance for the chosen frequency and resistivity range, a maximum rejected-realization rate, held-out volume or column recovery metrics, field-domain coverage, and the ordinary impedance misfit. A tiny linear-system residual only establishes that one discrete system was solved; it must not be substituted for a half-space or layered benchmark, mesh-refinement evidence, or blind field performance. Metrics calculated on accepted forward realizations also cannot hide rejected attempts: the requested and accepted counts belong in the observed evidence used by the gate.
6.3.11.4. The complete configuration#
ExperimentConfig is where a
DatasetReference, a SeedPlan, and a list of
AcceptanceCriterion join model/training/physics sections
that are frozen and hashed without being interpreted. That
restriction is deliberate: this class has no opinion on whether
{"architecture": "unet"} is a valid U-Net configuration, because
answering that would require importing the very training framework
this package exists to stay independent of. Validating those
sections is Training AI inversion models’s and AI model selection’s job, not
this one’s — its own job is to make sure the sections used for a
given run are pinned exactly and reproducibly.
>>> config.config_hash == config.config_hash
True
>>> config.child_seed("network") == config.seeds.derive("network")
True
The stage field is not free text either: it must be one of eleven
fixed values, and those eleven are not arbitrary — they are
Architecture roadmap’s own M0-M10 stages, spelled as portable identifiers
instead of milestone codes.
Milestone |
|
Roadmap scope |
|---|---|---|
M0 |
|
Baseline freeze and reproducibility |
M1 |
|
Survey data contract and audit |
M2 |
|
Correlated geological priors |
M3 |
|
Domain-gap and noise simulation |
M4 |
|
Genuine 2-D electromagnetic forward path |
M5 |
|
Response-aware 2-D learning |
M6 |
|
3-D Maxwell solver feasibility |
M7 |
|
Verified 3-D forward backend |
M8 |
|
Correlated 3-D training and spatial model |
M9 |
|
Hybrid inversion and uncertainty |
M10 |
|
Blind evaluation and release |
An ExperimentConfig therefore cannot claim to belong to a
stage that Architecture roadmap does not itself define, which is what makes
config.stage a meaningful, checkable fact about a run rather than
a free-form label a spreadsheet would need to interpret by
convention. Saving and reloading the record is a plain, deterministic
JSON round trip, exactly like Canonical data contracts’s manifests and
audit reports:
For 3-D work, the uninterpreted physics mapping should nevertheless be
complete enough for a reviewer to distinguish computations. At minimum pin:
solverand its implementation version, such asmt3d-pythonversusmodem3d-external;the forward-problem or solver-input hash and, for an external backend, the executable/version identity and invocation configuration;
frequency order, requested tensor components, permeability convention, and receiver coordinates;
the geological grid, padded solver mesh, and output grid definitions or their content hashes; and
boundary padding,
max_mesh_cells,cells_per_skin_depth, convergence policy, terrain/inactive-cell support, and accepted/rejected realization counts.
Inv3DAgent(physics="mt3d") currently selects the research
MT3DAdapter training route; it does not prove that the compiled
ModEm3DAdapter ran. Conversely, a ModEM response converted to the shared
SurveyData contract remains a ModEM-generated artifact. Preserve the
backend identity from the clean parent realization through corruption,
training, prediction, and reporting instead of replacing it with the generic
dimensionality label mt3d.
>>> from pathlib import Path
>>> from tempfile import TemporaryDirectory
>>> with TemporaryDirectory() as directory:
... path = config.write_json(Path(directory) / "experiment.json")
... reloaded = ExperimentConfig.read_json(path)
... print(reloaded.config_hash == config.config_hash)
True
A config_hash mismatch after that round trip would mean the
configuration was not actually reproduced — reloading it and getting
the same digest back is the check, not an assumption.
The digest is computed from canonical JSON over every serialized field,
including created_utc. Mapping key order does not affect it, while a
changed threshold, tag, timestamp, dataset hash, or nested model value does.
Consequently, two scientifically identical configurations created with
different timestamps have different hashes. If the hash is intended to
identify a protocol rather than an individual record, either hold
created_utc fixed or leave it None and timestamp the execution record
instead. The in-memory mappings and sequences are recursively frozen, which
prevents accidental mutation after the hash has been reviewed.
6.3.11.5. One frozen protocol still needs repeated runs#
Neural optimisation is stochastic even when the dataset and protocol are
fixed. A result from one favourable network seed estimates neither central
performance nor run-to-run instability. Keep one ExperimentConfig
and derive labels such as network/run-000 through network/run-011;
then report every run, the aggregate distribution, and the fraction satisfying
the complete gate. Do not alter experiment_id or thresholds after seeing
which seeds fail.
The diagnostic below makes that distinction concrete. Every point belongs to the same frozen configuration and dataset; colour indicates the joint gate, not whether the point passes the panel in which it appears. Run 4, for example, has acceptable recovery RMSE and coverage but fails because NRMS is above 2.0. Run 5 fails only the RMSE threshold, while run 6 fails coverage. Seven of twelve runs pass all three criteria. Reporting only their mean would hide both the failure modes and the probability of obtaining an acceptable training outcome.
Resuming a long 2-D or 3-D training run is the continuation of the same experiment only when the checkpoint agrees with the frozen protocol. Before loading it, verify the experiment, manifest, split, normalization, and model schema hashes; after loading, retain the completed epoch, optimizer and scheduler states, network weights, and every available random-generator state. If the current checkpoint format does not serialize one of those states, record that limitation and treat bitwise continuation as unproven. Changing the realization count, split, frequency grid, depth grid, feature order, solver backend, or loss weights creates a new experiment configuration, even when training starts from old weights. A checkpoint file existing on disk is therefore not itself evidence that L26, L30, or any other line finished its configured gate.
A pinned experiment protocol followed by per-seed joint-gate evaluation. Dashed lines are thresholds fixed before the metrics were generated.#
6.3.11.6. What the configuration does not prove#
An ExperimentConfig is deliberately a source-of-truth input, not
an experiment tracker or execution attestation. Its hash does not prove that
the declared solver, dataset, or seed was actually used, and the current
pycsamt.ai.experiments package does not automatically capture runtime
artifacts. A reproducible hand-off must therefore pair the configuration JSON
and its hash with, at minimum:
the observed metrics and serialized
GateEvaluationstate;derived seed labels and values for every stochastic subsystem;
source revision, pyCSAMT and dependency versions, solver backend, numerical precision, device, and deterministic-runtime settings;
checkpoint, prediction, log, and report checksums; and
start/end timestamps plus termination status.
Those are execution evidence, whereas the configuration is the approved protocol. Keeping the two roles separate prevents a common provenance error: a perfectly hashed declaration being mistaken for proof that the declared computation took place. The broader packaging workflow in AI inversion reporting supplies the natural home for that evidence.