Running a refinement¶
The parameter table is the table of parameters a fit can move. This chapter is the run itself: which call to make, what the intensities are allowed to do, how the stages are chosen and settled, and how to watch a run or stop one that is going nowhere.
How a refinement works explains why a refinement is staged and what order the presets encode. Nothing here repeats that argument. What follows is the machinery around it — the settings a caller chooses, and the record a run leaves behind.
Two entry points, and where the settings live¶
refine runs one refinement and returns a RefinementResult:
import rietx as rx
result = rx.refine(data, structure, instrument, plan="mccusker_default")
Refinement is the same machinery kept open, so the model survives the fit and
can be edited, inspected and re-fitted:
ref = rx.Refinement(structure, instrument)
result = ref.fit(data)
The two take different arguments, and the split is worth reading once because it is not arbitrary.
Setting |
Where it goes |
Why there |
|---|---|---|
|
the |
they decide how every residual and Jacobian in that object is computed, so they belong to the object rather than to one fit |
|
the |
a tree spans many fits (The refinement history) |
|
|
one question asked of one pattern |
|
|
they belong to a single run in flight |
So Refinement.fit takes no backend= or solver=. Choose those when you
build the object:
ref = rx.Refinement(structure, instrument, backend="numpy", solver="trf")
backend selects how the arrays are computed and solver selects the
least-squares driver. numpy and trf are the defaults and the answer for
almost every caller; Installation covers what the optional backends buy and
what they cost, and Capabilities.backends and Capabilities.solvers report
what the build in front of you actually has.
Refinement.result_ holds the most recent result, so a caller that fitted in
one place can read it in another without threading the return value through:
ref.fit(data)
print(ref.result_.statistics.rwp)
Modes: what the intensities are allowed to do¶
mode decides where a reflection’s intensity comes from.
Mode |
Intensities |
Use it when |
|---|---|---|
|
computed from the structure |
you have a structural model and want to refine it |
|
extracted from the data, iteratively |
you want the best profile fit a cell and symmetry can give, with no structure |
|
refined, one per reflection |
as Le Bail, but with the intensities as real parameters carrying esds |
The mode is not a detail of the plan; it changes which rows of the parameter
table can move at all. Le Bail and Pawley force-fix every atom parameter, every
phase scale and every emission-line intensity, because in those modes the data
does not constrain them. The parameter table shows that as the mode_fixed hold
reason, and explains why it is kept distinct from a lock.
RefinementResult.mode echoes the mode a result was produced under, so a
stored result cannot be misread later:
result = ref.fit(data, mode="lebail")
assert result.mode == "lebail"
Capabilities.modes lists them, for a program that offers the choice.
Choosing a plan at run time¶
How a refinement works introduces the seven presets and the order they encode. What that section does not give a program is a way to offer the choice without hard-coding a list that will rot the next time a preset lands.
PLAN_PRESETS maps each preset name to the function that builds it, and
PLAN_INFO maps the same names to a PlanInfo describing what the preset is
for:
import rietx as rx
assert set(rx.PLAN_INFO) == set(rx.PLAN_PRESETS)
info = rx.PLAN_INFO["lab_calibrate"]
print(info.title, info.description, info.modes, info.when_to_use)
Field |
Holds |
|---|---|
|
a short label, for a menu |
|
what the stages do, in order |
|
the modes the plan is meaningful in |
|
the condition that should select it |
PlanInfo.modes is a tuple rather than a single mode because a plan can be
meaningful in more than one: profile_only is both the Le Bail plan and the
way to fit a profile in Rietveld mode without touching the structure.
The two registries are held in bijection by a meta-test, so a preset added
without a PlanInfo fails the suite rather than shipping as a preset nobody
can be told when to use. Capabilities.plans carries the same four facts
through the JSON surface as PlanCapability, keyed by PlanCapability.name;
Calling rietx from a program reads that side.
A plan also carries one setting of its own. RefinementPlan.correlation_guard
is the |ρ| above which a stage reports a correlated pair, default 0.98:
import rietx as rx
plan = rx.RefinementPlan.mccusker_default()
assert plan.correlation_guard == 0.98
strict = rx.RefinementPlan(stages=plan.stages, correlation_guard=0.9)
Lowering it reports more pairs. It does not change the fit — the guard measures the fit, it does not constrain it.
What a stage carries¶
A Stage is Stage.name, a list of globs in Stage.turn_on, and five numbers
that decide how that stage is solved. Stage.name is a label, carried through
to StageResult.name and to the event stream; Stage.turn_on is what the
stage frees, matched with fnmatch against the dot-paths of The parameter table.
How a refinement works covers Stage.restraint_weight_scale, the restraint schedule,
in full; the other four are here.
import rietx as rx
stage = rx.Stage("widths", ["instrument.profile.*"], max_iter=200)
assert stage.max_iter == 200
assert stage.seed == 0.0 and stage.strain_seed == 0.0
assert stage.lebail_cycles == 3
Stage.max_iter caps the least-squares iterations for that stage. A stage that
reaches it is reported with status max_iter rather than converged, which is
a result to read rather than an error to catch.
Stage.seed and Stage.strain_seed both exist to lift a parameter off an
exact zero that the solver cannot move away from, and they are not
interchangeable, because the two pathologies are opposite.
Stage.seedlifts any softplus-bounded parameter the stage frees to the given value. The softplus map’s slope at zero is itself near zero, so a coefficient starting at exactly zero has no gradient and never moves. The extinction and surface-roughness stages use it.Stage.strain_seedputs a freed but all-zero Stephens block on a small microstrain, in ppm of ΔM/M. Those coefficients are identity-transformed, soStage.seedcannot reach them, and their problem at zero is the exploding gradient of a square root rather than a dead one.
Both default to 0.0, meaning no seed.
Stage.lebail_cycles is the number of intensity-partitioning refreshes the
stage performs, and it applies in Le Bail mode only.
Persisting a plan¶
RefinementPlan and Stage are plain dataclasses, which is what makes them
pleasant to edit and useless to store. PlanSpec and StageSpec are their
serializable mirrors, and the conversions are explicit in both directions:
import rietx as rx
plan = rx.RefinementPlan.mccusker_default()
spec = rx.PlanSpec.from_plan(plan)
restored = spec.to_plan()
assert [s.name for s in restored.stages] == [s.name for s in plan.stages]
assert spec.correlation_guard == plan.correlation_guard
PlanSpec.from_plan and PlanSpec.to_plan are the two directions.
PlanSpec.stages is a list of StageSpec, with StageSpec.from_stage and
StageSpec.to_stage doing the same job one level down. A StageSpec mirrors
Stage field for field — StageSpec.name, StageSpec.turn_on,
StageSpec.max_iter, StageSpec.lebail_cycles, StageSpec.seed,
StageSpec.strain_seed and StageSpec.restraint_weight_scale — and
PlanSpec.correlation_guard mirrors the plan’s.
What is stored is the expanded plan: every stage in full, because that is what will run. There is deliberately no field recording which preset it came from, since such a field could disagree with the stages beside it.
PlanSpec.preset_name is therefore a method rather than a field, and it
answers the question by comparison — the registered preset this plan equals,
or None if it was edited:
import rietx as rx
spec = rx.PlanSpec.from_plan(rx.RefinementPlan.mccusker_default())
assert spec.preset_name() == "mccusker_default"
spec.stages.pop()
assert spec.preset_name() is None
That is what lets a plan editor label a menu, and the text document print
plan mccusker_default instead of eight stage lines, without either of them
trusting a stored label.
This is the form a plan takes in a history tree and in a .rex project, which
is why the round trip matters more than it looks: a stored plan is the record
of what was actually done.
Running one stage at a time¶
Refinement.fit runs a whole plan. Refinement.run_stage runs exactly one
stage against the current model and stops:
ref = rx.Refinement(structure, instrument)
ref.run_stage(data, rx.Stage("scale_bkg", ["phases.*.scale"]))
ref.run_stage(data, rx.Stage("cell", ["phases.*.cell.*"]))
Each call refines, commits a history node and leaves the model at the values it
reached, so the next call starts from there. This is the same sequence a plan
performs; running it by hand is how a caller interleaves its own decisions
between stages. Refinement.suggest is built for exactly that moment, and
The refinement history is how to go back a stage when the decision was wrong.
run_stage takes its own correlation_guard rather than reading one from a
plan, because there is no plan involved.
Refinement.snapshot returns a RefinementState: the full state needed to
reconstruct the refinement exactly, taken without refining anything.
Refinement.stage_reports_ holds the per-stage reports from the last fit,
when it was asked for them:
ref.fit(data, stage_reports=True)
for report in ref.stage_reports_:
print(report.statistics.rwp)
That is off by default and worth asking for deliberately: a converged run’s final report is routinely its least informative, because a plan absorbs an error it cannot free into whatever it can. The fit report has the argument and what to read in a trajectory.
What each stage reports¶
RefinementResult.stages is a list of StageResult, one per stage, in the
order they ran. It is the cheapest honest account of what a fit did:
for stage in result.stages:
print(stage.name, stage.status, stage.n_iterations,
stage.cost_initial, stage.cost_final)
Field |
Holds |
|---|---|
|
the stage’s name, as the plan gave it |
|
|
|
least-squares iterations taken |
|
the cost the stage started from |
|
the cost it reached |
|
the paths this stage actually freed, after globbing |
|
steps the bounded-LM driver shortened to stay inside a linear-inequality constraint |
StageResult.freed is the field to read when a stage did nothing: a glob that
matches no path is not an error, so an empty list means the stage was a no-op
and the run continued past it in silence.
A cost that rises from StageResult.cost_initial to StageResult.cost_final
is a diverged stage, and StageResult.status says so.
StageResult.n_constraint_truncations is 0 under the default trf solver,
which has no linear-inequality vocabulary at all. It counts only under
solver="lm", and today the only such constraint is the Stephens strain cone.
Guards¶
A guard is a measurement taken after a stage converges. It never changes the fit; it reports something about the fit that the fit statistics cannot show.
Every hit is a GuardFinding, which carries the finding as data rather than as
a sentence:
import rietx as rx
finding = rx.GuardFinding.correlation("instrument.zero_shift",
"instrument.geometry.sample_displacement",
-0.997)
assert finding.code == "HIGH_CORRELATION"
assert finding.paths == ("instrument.zero_shift",
"instrument.geometry.sample_displacement")
assert finding.value == -0.997
print(finding.message)
GuardFinding.code, GuardFinding.paths, GuardFinding.value and
GuardFinding.message are the four fields. A client reads paths to offer a
link and value to sort; nothing has to take a string apart. str(finding) is
GuardFinding.message.
There is one constructor per kind of finding, so each format string is written once:
Constructor |
Fires when |
|---|---|
|
two free parameters correlate above the plan’s |
|
a parameter stopped against a bound |
|
the background could largely reproduce a structural parameter’s column |
|
the roughness correction could |
|
an anisotropic displacement tensor is not positive definite |
|
a Stephens block gives a negative σ²(M) for some reflection |
GuardFinding.value is the headline number for the kind — the correlation
coefficient, the block R², the minimum eigenvalue, the worst σ²(M). It is
None for GuardFinding.at_bound, which has no number to report.
code is an open vocabulary of strings and deliberately not a closed type: it
is the same vocabulary as Diagnostic.code, so the mapping from a guard to the
diagnostic a caller reads is data rather than a hand-written branch per kind.
Reading the numbers reads diagnostics.
Read a guard as evidence about what the data could support, not as a verdict on
the model. A nonpositive_strain finding means those coefficients are not
quotable; it is not a measurement of anisotropy.
Watching a run¶
events streams per-iteration telemetry out of a running fit. It accepts a
path, in which case each event is appended to that file as JSONL:
result = ref.fit(data, events="run/events.jsonl")
or a callable, called with each event as a plain dict:
def show(event):
print(event["kind"], event["data"])
result = ref.fit(data, events=show)
Each event carries its kind, a Unix timestamp, and an open data dictionary.
The kinds are a closed set — fit_start and fit_end for the run,
stage_start and stage_end for each stage, and eval for each residual
evaluation — and rietx watch renders a log as a live console.
Two rules matter to anyone consuming the stream. Read data with .get rather
than by unpacking a fixed shape, because fields are added to a kind without a
version bump. And a cancelled run’s fit_end omits the statistics entirely,
because there is no fitted result to report.
Capabilities.event_schema_version reports the version a build emits;
Compatibility says what that version promises.
Stopping a run¶
cancel takes a CancelToken, which another thread sets:
import rietx as rx
token = rx.CancelToken()
assert not token.is_set()
token.cancel()
assert token.is_set()
token.reset()
Cancellation is cooperative. The token is read between residual evaluations, never as an interrupt, which is what keeps the frozen-per-stage state of a compiled model intact.
The stage in flight is abandoned: no history node, no commit, and the models restored to the values they held before that stage began. That is not tidiness — a seeding stage writes to the models before it solves, so leaving them alone would leave a half-seeded model behind.
RefinementCancelled is then raised, carrying what did complete:
try:
ref.fit(data, cancel=token)
except rx.RefinementCancelled as cancelled:
print(cancelled.stage) # the abandoned stage's name
print(cancelled.completed_stages) # list[StageResult] for those that finished
print(cancelled.node_id) # the node the working state stands at
RefinementCancelled.stage is the abandoned one. RefinementCancelled.completed_stages
is empty when the first stage was cancelled. RefinementCancelled.node_id is
None with history disabled, or when nothing completed.
A cancelled run is therefore not a lost run: the working state is a real, restorable node (The refinement history), and the stages before it are reported in full.
What the result records about the run¶
RefinementResult.provenance is a Provenance, and it holds everything needed
to reproduce the result:
p = result.provenance
print(p.package_version, p.backend, p.solver, p.dtype)
Field |
Holds |
|---|---|
|
the version of rietx that produced it |
|
the data-contract version of the objects |
|
the backend the arrays were computed on |
|
the precision they were computed in |
|
the least-squares driver used |
|
the thresholds any report was judged against |
|
when the result was assembled, UTC |
|
free-form string pairs a caller can add |
Provenance.backend, Provenance.dtype and Provenance.solver are worth
reading back rather than assumed: a result is only as reproducible as its
record of how it was computed, and that record is the only place the answer
survives once the calling code has moved on.
The four version fields are the same contracts capabilities() reports.
Compatibility says which are frozen and what a change to one means.