Refining many patterns¶
There are two ways to have more than one pattern, and they are not variants of each other. A series is N separate refinements, ordered, each starting from the one before it. A joint fit is one refinement whose residual is several patterns stacked together, sharing the parameters that describe the specimen.
Series |
Joint fit |
|
|---|---|---|
Residual |
N of them, solved one at a time |
one, with every pattern’s points in it |
What crosses a pattern |
the starting values |
the shared parameters themselves |
Answers |
a trajectory: a(T), w(t) |
one set of numbers, informed by every pattern |
Use it for |
an in-situ ramp, a parametric sweep, a tray of specimens |
one specimen measured twice, at two wavelengths or on two instruments |
Entry point |
|
|
Modes |
any |
Rietveld only |
The question that separates them is whether the specimen changed. Eight mixtures with different compositions are eight specimens, so their cells are eight measurements and a series is the right shape. One powder measured at two wavelengths is one specimen, so its cell is one number and a joint fit is.
A series is N refinements, chained¶
SequentialRefinement takes the starting models once and the patterns at
SequentialRefinement.fit.
import rietx as rx
series = rx.SequentialRefinement(structure, instrument)
result = series.fit(patterns, x=temperatures, x_label="T (K)")
refine_sequential is the same run as one call, and it is what most code
wants:
result = rx.refine_sequential(patterns, structure, instrument,
x=temperatures, x_label="T (K)")
Where x comes from¶
x is the series coordinate: the quantity the experiment varied, and on an
in-situ run the point of the experiment. A vendor file records it where its
format has a field for it, and the reader puts it in the pattern’s own
metadata:
import rietx as rx
patterns = [rx.read_pattern("ramp.raw", scan=i) for i in range(68)]
temperatures = [float(p.metadata["temperature_k"]) for p in patterns]
PatternData.metadata holds strings, so the conversion is yours. Read the key
with dict.get and refuse rather than substitute when it is missing. An absent
key is a file that recorded nothing, and not a specimen at ambient. Today the
Bruker .raw v3 range header is the one format here with such a field; the
others record no specimen temperature, and a reader will not guess one from an
axis named for something else.
rietx.io.readers.list_scans answers the same question without reading the
patterns. It returns one rietx.io.formats.base.ScanInfo per scan, each
carrying index, n_points, the stepped range, and the temperature where the
file gave one, which is also what its label says, since the scans of a reel
are otherwise indistinguishable from each other.
Both return a SeriesResult. The class keeps more: after a fit,
SequentialRefinement.results_ holds each pattern’s full RefinementResult
with its curves, SequentialRefinement.trees_ holds the per-pattern histories,
SequentialRefinement.result_ is the SeriesResult that was returned, and
SequentialRefinement.backward_ is the backward chain when one was run.
SequentialRefinement.fitted_structures and
SequentialRefinement.fitted_instruments are each pattern’s refined models in
series order, one per pattern, because nothing here is shared.
SequentialRefinement.structure and SequentialRefinement.instrument are the
package’s own deep copies of the models you passed, so your originals are not
moved by the fit.
Four settings are constructor arguments, because they describe the chain rather
than a run of it: backend, solver, history, and
SequentialRefinement.carry. Running a refinement has the full table of which
setting goes where.
history behaves as it does for a single refinement (The refinement history) with one
addition: given a directory, each pattern’s tree is written to
<dir>/<label>.jsonl. There is one tree per pattern and never one for the
series, because a tree is pinned to its pattern by a data fingerprint, and that
pin is what stops a node being replayed against the wrong data. The chain is
recorded instead as annotation notes on each tree’s root node. The default is
False: a long series makes a lot of trees.
What fit takes¶
Argument |
Does |
|---|---|
|
the series, in order. Each keeps its own σ; patterns are never pooled |
|
the series coordinate and its name. Without one the pattern index is the axis |
|
a name per pattern, used in messages, tables and history filenames |
|
as for a single fit: |
|
the plan run on the first pattern and on any reseeded one |
|
|
|
applied to every pattern |
|
|
|
the fence that rejects a bad warm start, and how far above the median Rwp it fires |
|
how much the first rung may spend before the ladder gives up on it, as a multiple of the most expensive first rung this chain has converged. |
|
|
|
|
|
as on |
refit sets the ladder’s first rung, which is not the only plan a pattern can
be fitted with. The staged order exists to keep early stages well conditioned
from a poor starting model, and a converged neighbour is not one, so the default
collapses it. When the neighbour turns out not to be a good starting point
either, the fence below catches it.
What crosses a pattern boundary¶
SequentialRefinement.carry is a list of dot-path globs (fnmatch, the
Refinement.set_vary convention) naming which parameters are carried forward.
The default is ["*"], meaning everything. A parameter excluded from it
restarts from the initial model on every pattern, and not from its neighbour.
Carrying everything is cheap even when it looks reckless. Measured on the eight IUCr round-robin sample-1 mixtures (three phases, one goniometer, 7251 points each over 5–150°, and a composition that swings from 1.8 to 94.2 wt % across the set), the chain took 816 least-squares iterations against 2789 for the same eight patterns fitted independently, a factor of 3.4, and every pattern converged on its first rung.
prepare is for what a carry glob cannot express: a parameter that must be
re-estimated from this pattern rather than either carried or left at its initial
value. Excluding the phase scales from carry on that round-robin series would
only fall back to the first mixture’s guess, which is not the same thing as
estimating them afresh.
What comes back¶
SeriesResult is the serializable answer. It stores summaries rather than
curves: nine patterns’ worth of y_obs/y_calc/y_background/sigma is
about 2 MB of JSON that is already on disk as the input files, while the
refined values, their
esds, the agreement indices and the diagnostics are what a series is for and are
a few kB. The curves stay reachable on SequentialRefinement.results_.
Member |
Holds |
|---|---|
|
one |
|
the mode the series was fitted in |
|
|
|
the reverse chain’s own |
|
the axis name |
|
the series-level fences below |
|
package version, timestamp and settings, as for a single fit |
|
the axis: the coordinate given, or the pattern index |
|
one label per entry |
|
one Rwp per entry |
|
least-squares iterations summed over the entries: the reported chain, not the run |
It iterates, indexes and has a length, so for entry in result walks the
patterns in order.
One pattern’s entry¶
SeriesEntry is that pattern’s place in the series and how its fit went.
Field |
Holds |
|---|---|
|
position in the series |
|
its name |
|
its coordinate, or |
|
|
|
that pattern’s own |
|
the |
|
the phase quantities, when the fit produced them |
|
per-phase |
|
that pattern’s own diagnostics |
|
iterations over every attempt on this pattern |
|
the warm start was rejected and the pattern was refitted cold |
|
Rwp the first, warm attempt reached, set whenever the ladder escalated |
|
which attempt produced these values: |
|
every rung attempted, in ladder order |
|
where this pattern’s history lives |
SeriesEntry.value and SeriesEntry.stderr look a path up in that entry’s
parameters and return None when it is not there.
SeriesEntry.phase_agreement is not the test of whether a minor phase is real,
and the temptation to read it as one is why it needs a paragraph. Both indices
are biased towards the model being tested, and a trace phase’s is not comparable
with the major phase’s at all; Structure agreement indices has the
mechanism and the in-repo measurement. What the field is for here is the
trajectory. One pattern’s R_B is a value, sixty of them are a shape, and
watching a phase’s R_B walk across a ramp is a use a single fit cannot make of
it.
The question it looks like it answers has its own channel.
PHASE_UNCONSTRAINED measures each phase’s strongest modelled point in σ of the
observation noise, which is whether the data can see this phase at all. It
reaches an entry through SeriesEntry.diagnostics, and aggregates over the
chain as SEQUENTIAL_PERSISTENT_FINDING, which says the thing no per-pattern
diagnostic can: 42 of 68. Read R_B beside that and beside the weight with its
esd.
A phase that appears part-way through¶
This is the ordinary shape of an in-situ series: a product phase is in the model from the first pattern and in the specimen only from some point on. Below that point the fit holds its structural parameters, because they cannot be measured against a phase the data cannot see, and the entries say so three ways at once:
SeriesEntry.parametersdoes not list them. A held parameter was not in the free vector, has no esd and did not move, so it is absent rather than present with a number beside it.SeriesEntry.diagnosticscarriesPHASE_UNCONSTRAINED, naming the phase and the stages that held it.the fitted model keeps the value you supplied. Read it as your own input, never as a measurement that happens to agree with it.
Above that point nothing is held and the parameters refine normally, including in the pattern where the phase first appears, which is the one an operator reads. If it appears while a stage is still solving, that stage lifts the hold and solves again rather than deferring to the next pattern.
A trajectory follows from that. SeriesResult.trajectory reads the entries’
parameter lists, so a held pattern contributes no point at all. Trajectory.x
begins at the onset, and its length is the number of patterns that measured the
parameter rather than the number in the series. Before that, the same trajectory
ran the whole way with a stretch of values that were never measurements.
SeriesEntry.rung and SeriesEntry.reseeded answer different questions. rung
says where the numbers came from, and the first pattern of a chain is always
"cold" because it has no predecessor. reseeded says whether the chain was
broken here, and only that has a fence. The middle rung does not set it:
"warm_staged" is still a warm start, so the chain is unbroken there.
The trajectory¶
SeriesResult.trajectory returns one parameter’s path across the series as a
Trajectory.
Member |
Holds |
|---|---|
|
the dot-path |
|
the axis and its name |
|
the value at each point |
|
its esd, |
|
the pattern label at each point |
|
which entry of |
|
|
Patterns where the path is absent are skipped rather than filled. A gap in a
trajectory is a real thing, a phase that was not in the model yet or a stage
that did not run, and inventing a value for it would be the confident wrong
singleton the whole package gates against. len(trajectory) is therefore the
number of points that have a value, and not the number of patterns.
SeriesResult.qpa_trajectory does the same for a phase’s weight fraction,
converted to a percentage, and SeriesResult.agreement_trajectory for a phase’s
structure agreement index. That takes metric="r_bragg" (the default) or
"r_f", the two McCusker indices, with SeriesResult.agreement_phases listing
the phases that carry one. A phase can appear there without appearing in the
QPA, because a weight fraction needs Z and a molar mass and a structure R does
not.
An empty esd column is a fact rather than a gap
For the other two trajectories a None esd means this pattern did not estimate
one. For an agreement index every entry is None, always. R_Bragg is a residual
rather than a fitted parameter, so there is no covariance entry to propagate
from. arrays() turns them into the NaNs an errorbar ignores, so a plotting
caller needs no special case.
Read a trend rather than a value. A single R_B is not comparable between phases and a low one is consistent with a self-fulfilling partition. One phase’s index moving across a ramp is a statement about that phase.
Outside Rietveld mode the trajectory is empty rather than zero. In Le Bail the partition is the fit and in Pawley the intensities are refined, so the index does not exist there and a zero would read as a perfect fit.
SeriesResult.resolve_trajectory is the single entry point behind all three.
Hand it a display path and it returns the right curve, dispatching on the
qpa. / r_bragg. / r_f. prefix and falling through to trajectory for an
ordinary parameter dot-path. Prefer it to a hand-written conditional: the
plotting and GUI layers each carried their own copy of that conditional until it
became one authority. SeriesResult.is_derived_path answers the yes/no behind
that dispatch, which is whether a display path names a derived curve (a QPA or
an agreement index) rather than a refined parameter. A caller that must skip the
forward/backward comparison a residual has no σ for asks it.
SeriesResult.paths lists every parameter path
present anywhere in the series in first-seen order. Its varied_only argument
drops the tied paths; the default keeps them, because a hexagonal cell.b is
not free but is every bit as measured as cell.a. On the round-robin series
that is 49 paths, 41 of them varied.
SeriesResult.to_table returns (header, rows) in the wide form, one row per
pattern with a value and an esd per parameter, and SeriesResult.write_csv
writes it, inferring a tab delimiter from a .tsv or .tab suffix and a comma
otherwise. The columns are index, label, x, status, rung, rwp, gof, then each
path followed by its esd. rung travels beside status because it is the other
half of “how much should I trust this point”. A rescued point is a good fit
whose starting values did not come from its neighbour, and a table that hides
that reads as a continuous trajectory.
paths takes the derived kinds too. to_table(paths=["qpa.LaB6"]) exports the
weight-fraction curve, and r_bragg. and r_f. export the agreement indices,
all three resolved by SeriesResult.resolve_trajectory, the same call the plots
and the GUI use. Two consequences. A path no pattern in the series carries
raises, naming it and listing what does exist, rather than returning a column of
blanks. And a kind with no esd by construction gets no _esd column at all: an
agreement index is a residual, so its esd column would be empty in every row of
every series. A kind that does have esds keeps its column even where this series
estimated none, because there a blank says that this pattern did not estimate
one.
Where a trajectory skips a pattern, the table still has a row for it and leaves
the cell empty. Trajectory.positions is what keeps the two aligned, since a
trajectory is a subsequence of the series and neither x nor label identifies
an entry on its own.
The axis column takes SeriesResult.x_label, unless that name is already one
of the fixed columns, in which case it is x. That is what the default hits:
x_label is a human label and defaults to "index", which reads correctly as
an axis title for a series with no coordinate but would be the header’s second
index. The column count, order and meaning do not change either way.
SeriesResult.plot plots one or more trajectories against the series axis.
The series fences¶
A sequential fit is path-dependent by construction. Every pattern’s answer depends on its neighbour’s, so the method can imprint a trend the data do not carry: one bad pattern’s error is inherited by all its successors, and the result is a smooth-looking curve. Five diagnostics fence that, and none of them alters a fitted value.
Code |
Says |
|---|---|
|
the warm start was rejected and the pattern was refitted cold, so the chain was not poisoned silently |
|
the pattern diverged and stayed diverged after every rung; it seeded no successor and joined no median |
|
a step much larger than the local trend: the science, or a chain failure, and the diagnostic says both |
|
with |
|
one of the per-pattern codes fired in more than half the patterns, so it is about the model rather than about a pattern |
The last one exists because of an arithmetic problem the others do not have. A
per-pattern diagnostic can only say “this pattern”. In a run of 68 it therefore
cannot say “42 of 68”, and that is the sentence you act on, because one
BOUND_HIT is a pattern that hit a bound while a BOUND_HIT in most of them is
a bound that is wrong. It counts each (code, parameter) pair over the entries
and states the fraction once, in value; the per-entry diagnostics still carry
every occurrence. The threshold is half the series, which is a change of subject
rather than a sensitivity: above it the finding describes the series, below it
the per-pattern diagnostics already say everything there is to say.
For agents
Read SeriesResult.diagnostics before any trajectory. A series is where a
single unread warning multiplies: in the episode this diagnostic comes from,
425 BOUND_HITs went unread for two hours across a 68-pattern in-situ run, and
the parameters they named were quoted as a measured trajectory.
What to do about each is the agent skill’s diagnostic table, which this chapter does not restate.
Checking a step against two independent fits¶
SEQUENTIAL_DISCONTINUITY names both readings, the science or a chain failure,
and asks you to open that pattern’s own fit before choosing one.
verify_discontinuities=True runs that check for you. Each flagged step’s two
patterns are refitted cold and independently, with no warm start and no
neighbour, and what the pair reproduces goes on the diagnostic as value, the
cold step over the chain’s:
series = rx.refine_sequential(patterns, structure, instrument, x=temperatures,
verify_discontinuities=True)
Near 1.0 the step is in the data. Near 0 the chain made it: the two patterns
agree when nothing carried an error between them. The ratio is signed, so a cold
pair that stepped the other way reads as about −1 rather than as a reproduction.
Nothing else changes. The refits are separate Refinement runs writing to their
own <label>.verify histories, and no fitted value, rung or median moves
because of one.
It is off by default because it is not free. A cold fit is the full staged
plan from the initial models, and a series flagging s steps pays up to 2s
of them, once per pattern, since two paths flagged at the same step share a
refit. Measured on a 68-pattern thermal ramp flagging four steps over four
patterns: 11.6-12.0 s for the chain and 12.1-12.2 s with the check, +5 %.
The cost scales with the patterns flagged, not with the series length.
The ladder, and quarantine¶
A rejected warm fit escalates one rung at a time: the collapsed warm refit,
then the full staged plan from the warm state, then the full staged plan cold.
Each rung runs only when the fence still fires on the best attempt so far, and
the best attempt kept whichever rung produced it. SeriesEntry.rungs_tried
names them, so the escalation is auditable, and SeriesEntry.n_iterations is
the sum over exactly those.
The middle rung is the one that matters. Throwing a warm start away costs roughly triple, and before it existed that was being paid for a starting point that had not been shown to be the problem.
The first rung is bounded, because it is a guess rather than the answer. Its
budget is first_rung_factor times the most expensive first rung this chain has
already converged, and it applies only once a few of them have. A chain whose
collapsed refit always works stays well clear of the bound. A first rung
that spends its budget escalates rather than being kept, and the rung it
escalates to starts from the same warm state, so the values a bounded chain
reports are the values it would have reported without the bound. Measured on the
benchmark’s ten-pattern series: 1603 solver evaluations without the bound and
1395 with it, both converging to Rwp 0.01943, with every accepted value
identical. On the eight-mixture round-robin series the two runs are identical to
the evaluation. Set first_rung_factor=None to reproduce a pre-1.1 run exactly.
Quarantine is the other half, and it is about what the chain carries rather than
what it reports. A fit still "diverged" after the last rung is neither a
starting point nor a scale, so its successor warm-starts from the last accepted
pattern and the reseed median never sees the failure. Otherwise one failure
would seed its neighbour with rubbish and drag the median that decides every
later trigger, quietly raising the bar for the rest of the series.
What triggers the ladder is deliberately narrow: divergence, or an Rwp above
reseed_factor times the median of the accepted patterns. Two candidates were
considered and rejected, and the reasons are the rule a new trigger has to
satisfy. Guard findings such as HIGH_CORRELATION fire legitimately on
perfectly converged patterns, and no rung changes the model or the data that
produced them. A discontinuity is a property of the whole finished trajectory,
so making it a trigger would mean re-walking a finished chain. What the two
accepted triggers share: each is a property of this pattern’s own fit, readable
the moment it finishes, and each is something a different starting
point could plausibly fix.
Running the chain both ways¶
direction="both" runs the series forward and backward and compares the two
trajectories. The reported SeriesResult.entries are the forward ones; the
comparison arrives as SEQUENTIAL_PATH_DEPENDENT diagnostics, one per parameter
that disagrees.
It is the only check that separates a measured trajectory from an ordering
artefact, and on real data it is selective. On the round-robin series it flagged
nine parameters, and every one of them was a broadening term:
phases.*.lor_size, gauss_size, gauss_strain, lor_strain,
instrument.profile.x and instrument.geometry.axial_sl. No cell parameter and
no scale was flagged. The trajectories anyone would plot from that series were
order-independent, and the widths were not.
Two things to know before reading the σ multiples in those messages. They are
ratios to a fitted esd, so a parameter sitting near zero with an esd near zero
reports a spread of thousands of σ that is not a physical scale. Read the two
values the message quotes rather than the multiple. And the trajectory the
messages compare against is SeriesResult.backward, the reverse chain’s own
SeriesResult, set whenever direction="both" completed, so a run made through
refine_sequential can read the second trajectory and not only the verdict
about it. Its own backward is None, which is one extra level rather than a
cycle, and SequentialRefinement.backward_ is the same object.
SeriesResult.n_iterations counts the chain the result reports, which under
direction="both" is the forward one. It is not what the run cost:
result.backward.n_iterations is the rest. On the round-robin series both
chains come to 816, against a wall clock of 33.7 s forward and 83.7 s for
"both".
Telemetry, history and cancellation¶
events= and cancel= are per pattern. Every event a pattern’s fit emits
is forwarded with its place in the series stamped into the event’s data:
series_index, series_label, series_n and series_pass, plus series_rung
and series_cold on a restart. Those are added fields on existing kinds, so no
EventKind is new and the event schema version does not move. A consumer reads
data with .get and “pattern k of N” is readable off fit_start.
Cancelling a series returns what completed rather than raising. That is the
cancellation rule applied one level up. A series is N separate refinements, so
the pattern in flight is abandoned by
Refinement.fit itself (no node, no commit, models restored) while the
patterns already walked are finished fits with committed nodes. Raising would
throw those away. SEQUENTIAL_CANCELLED says how many of how many were reached,
so a short entries list is never mistaken for a short series.
For agents
A truncated series and a finished one are the same shape, and the only thing
that distinguishes them is SEQUENTIAL_CANCELLED. Check for it before reading
the last entry as the end of a ramp, or a slope over the entries as a slope over
the experiment.
Constraining a parameter across the series¶
Fitting a(T) to a functional form of T across every pattern at once, which is parametric refinement [Stinton and Evans, 2007], is a joint fit over the series, and it is deliberately not implemented. The fences above exist partly so that a sequential trajectory is never mistaken for one.