Batch PCA outlier diagnosis: the DuPont polymerization reactor#
Case study for issue #155.
Industrial nylon is made in a batch autoclave. The critical quality of a batch is measured in the laboratory about twelve hours after the batch has ended, so there is no feedback that could correct a running batch, and a long hold-up before its disposition is known. What the plant does have is the trajectory of ten process measurements (temperatures, pressures and flows) over every batch. The question of this case study is what those trajectories can tell about a batch, and what they cannot.
The data were supplied by DuPont and are the worked example of Nomikos and MacGregor (1995). Batches 40, 41, 42, 50, 51, 53, 54 and 55 had a final quality well outside the acceptable limit; batches 38, 45, 46, 49 and 52 were above or very close to it.
The complete script is dupont_batch_pca.py in this directory:
uv run python docs/user_guide/case_studies/batch/dupont_batch_pca.py --output-dir case-study-output/dupont
It prints the numbers quoted below and writes every figure as an HTML file to the output directory. The sections below quote it piece by piece.
Data#
Industrial batch polymerization:
55 batches, each already aligned to 100 equal time intervals, with ten tags
per interval. The values are scaled for confidentiality.
process_improve.batch.load_dupont() downloads the file and returns the
standard batch dictionary, one 100-by-10 frame per batch.
CONF_LEVEL = 0.95
SPE_OUTLIER = 49
SCORE_OUTLIERS = [50, 51, 52, 53, 54, 55]
DIFFERENT_BUT_ACCEPTABLE = [37, 39, 43, 44, 45, 46, 47, 48]
POOR_QUALITY_NOT_VISIBLE = [38, 40, 41, 42]
HIGHLIGHT = '{"color": "red", "width": 4}' # Plotly line style, JSON-encoded, for the highlighted batches
LABELS = {"show_labels": True}
def load_data(url: str | None = None) -> dict:
"""Download the 55 aligned batches; each is a 100-sample by 10-tag frame."""
batches = load_dupont(url=url)
first = next(iter(batches.values()))
print(f"{len(batches)} batches x {first.shape[0]} samples x {first.shape[1]} tags")
return batches
Looking at one tag across all batches is the first check. The trajectories overlay well (the alignment has already been done), and a few batches are visibly unusual, but a plot per tag cannot rank 55 batches on ten variables at once.
def plot_raw(batches: dict, tag: str, highlight: list[int]) -> go.Figure:
"""Overlay one tag for every batch, with the batches of interest drawn in red."""
return plot_all_batches_per_tag(
batches, tag, batches_to_highlight={HIGHLIGHT: highlight}, extra_info=f"highlighting {highlight}"
)
Model A: batch PCA on all 55 batches#
process_improve.batch.BatchPCA unfolds the batches batchwise: each
batch becomes one row of 10 tags times 100 samples, 1000 columns. Centring
the columns removes the average trajectory, and scaling them to unit variance
gives every (tag, time) cell the same weight, so the two components describe
how the batches deviate from the average batch.
def fit_model_a(batches: dict) -> BatchPCA:
"""Two-component batch PCA on all 55 batches.
Every batch becomes one row of 10 tags x 100 samples = 1000 columns; the
columns are centred (removing the average trajectory) and scaled to unit
variance, so the model describes batch-to-batch deviations.
"""
model = BatchPCA(n_components=2).fit(batches)
per_component = model.r2_per_component_.to_numpy()
print(
f"Model A: R2 per component = {per_component[0]:.3f}, {per_component[1]:.3f}; cumulative = {per_component.sum():.3f}"
)
print(
f"Model A: largest |t1| = {abs_top(model.scores_.iloc[:, 0], 4)}; largest |t2| = {abs_top(model.scores_.iloc[:, 1], 3)}"
)
spe = model.spe_.iloc[:, -1]
print(
f"Model A: largest SPE = batch {spe.idxmax()} ({spe.max():.1f} vs {CONF_LEVEL:.0%} limit {model.spe_limit(conf_level=CONF_LEVEL):.1f})"
)
return model
def abs_top(values: pd.Series, n: int) -> list:
"""Batch identifiers with the largest absolute values, largest first."""
return values.abs().nlargest(n).index.tolist()
Model A: R2 per component = 0.383, 0.176; cumulative = 0.559
Model A: largest |t1| = [54, 52, 50, 51]; largest |t2| = [53, 55, 50]
Model A: largest SPE = batch 49 (39.3 vs 95% limit 29.1)
Two components explain 55.9% of the variance in the unfolded matrix. The score plot shows batches 50 to 55 far from the rest; they pull the model towards themselves, which is the first sign that the model needs to be rebuilt without them. The SPE plot flags a different batch, 49, which sits in the score plot among the normal batches: its problem is not a large deviation along the main directions of variation but a break in the correlation structure.
Batch 49: which variables, and when#
The raw data are ambiguous about batch 49. Flow-1 looks suspicious in the
overlay, but it is a noisy tag in every batch. The SPE contributions settle
the question. The signed contributions are the residuals of every
(tag, time) cell after the two-component reconstruction; their squares add up
to the SPE, so the squares are each cell’s share of it. Summing the shares
per tag ranks the variables, and summing them per time sample locates the
event.
def diagnose_spe_outlier(
model: BatchPCA, batches: dict, batch_id: int = SPE_OUTLIER
) -> tuple[pd.DataFrame, pd.Series, pd.Series]:
"""Which variables, and when, push one batch off the model plane.
The signed SPE contributions are the residuals of every (tag, time) cell;
their squares add up to the batch's SPE, so the squares are each cell's
share of it. Summing the shares per tag ranks the variables; summing them
per time sample locates the event.
"""
spe_share = model.spe_contributions(model.unfold_and_scale(batches)) ** 2
by_tag = spe_share.loc[batch_id].groupby(level="tag", sort=False).sum()
by_time = spe_share.loc[batch_id].groupby(level="sequence").sum()
print(
f"Batch {batch_id}: SPE share per tag = "
+ ", ".join(f"{tag} {share:.0%}" for tag, share in (by_tag / by_tag.sum()).items())
)
peak = by_time.nlargest(7).index
print(f"Batch {batch_id}: the seven largest per-sample shares sit at samples {sorted(int(k) for k in peak)}")
return spe_share, by_tag, by_time
def plot_share_over_time(by_time: pd.Series, batch_id: int) -> go.Figure:
"""Bar chart of a batch's SPE share per time sample."""
fig = go.Figure(go.Bar(x=list(by_time.index), y=by_time.to_numpy()))
fig.update_layout(
title=f"SPE share per time sample, batch {batch_id}",
xaxis_title="Time [sequence order]",
yaxis_title="Share of SPE",
)
return fig
Batch 49: SPE share per tag = Flow-1 3%, Flow-2 18%, Press-1 5%, Press-2 15%, Press-3 12%,
TempC-1 19%, TempH-1 14%, TempR-1 7%, TempR-2 3%, TempR-3 4%
Batch 49: the seven largest per-sample shares sit at samples [57, 58, 59, 60, 61, 62, 63]
Flow-1 carries 3% of the residual. The residual belongs to the cooling and
heating medium temperatures and to the pressures, and it is concentrated in a
short window around samples 57 to 63: a small disturbance in the heating,
cooling and pressure systems during that stretch of the batch. Nomikos and
MacGregor report that the quality of batch 49 was barely acceptable, which is
consistent with a short event rather than a batch that was wrong throughout.
process_improve.batch.unfolded_contribution_plot() draws the full
vector of 1000 bars grouped by tag, and the same data summed per tag.
The score outliers#
Batches 50 to 55 are far out along the components, so the tool here is the
score contribution: how much every (tag, time) cell contributes to
\(t_1\) or \(t_2\). The loading \(p_1\), drawn as a function of
time with process_improve.batch.time_varying_loading_plot(), shows
which parts of the batch each component describes.
def diagnose_score_outliers(model: BatchPCA, batches: dict) -> dict[str, pd.DataFrame]:
"""Score contributions for the batches that stand out on t1 and t2."""
scaled = model.unfold_and_scale(batches)
contributions = {
"t1": model.score_contributions(scaled, component=1),
"t2": model.score_contributions(scaled, component=2),
}
for batch_id, component in ((54, "t1"), (55, "t2")):
by_tag = contributions[component].loc[batch_id].groupby(level="tag", sort=False).sum()
print(
f"Batch {batch_id}: {component} contributions per tag = "
+ ", ".join(f"{tag} {value:+.1f}" for tag, value in by_tag.items())
)
return contributions
Batch 54: t1 contributions per tag = Flow-1 +5.8, Flow-2 +4.6, Press-1 +5.9, Press-2 +7.4, Press-3 +8.1,
TempC-1 +7.4, TempH-1 +5.2, TempR-1 +7.5, TempR-2 +8.7, TempR-3 +6.9
Batch 55: t2 contributions per tag = Flow-1 +1.4, Flow-2 +1.7, Press-1 +3.2, Press-2 +6.8, Press-3 +8.5,
TempC-1 +6.3, TempH-1 +3.8, TempR-1 +0.8, TempR-2 +2.6, TempR-3 +0.6
Batch 54 has a high \(t_1\) because every tag contributes in the same direction: the whole batch ran away from the average trajectory. Batch 55 stands out on \(t_2\) through the pressures and the cooling-medium temperature.
Model B: exclude batches 49 to 55 and rebuild#
A reference model must describe normal operation, so the batches identified so far are removed and the model refitted on the remaining 48 batches, now with three components.
def fit_model_b(batches: dict) -> BatchPCA:
"""Rebuild without batches 49 to 55: a second cluster appears on t2 and t3."""
kept = {batch_id: batch for batch_id, batch in batches.items() if batch_id < SPE_OUTLIER}
model = BatchPCA(n_components=3).fit(kept)
per_component = model.r2_per_component_.to_numpy()
print(f"Model B ({len(kept)} batches): R2 per component = " + ", ".join(f"{value:.3f}" for value in per_component))
print(
f"Model B: largest |t2| = {abs_top(model.scores_.iloc[:, 1], 6)}; largest |t3| = {abs_top(model.scores_.iloc[:, 2], 6)}"
)
return model
def diagnose_different_batch(model: BatchPCA, batches: dict, batch_id: int = 39) -> pd.DataFrame:
"""Return the t3 contributions of a batch from the second cluster."""
kept = {key: value for key, value in batches.items() if key in model.batch_ids_}
contributions = model.score_contributions(model.unfold_and_scale(kept), component=3)
by_tag = contributions.loc[batch_id].groupby(level="tag", sort=False).sum()
print(
f"Batch {batch_id}: t3 contributions per tag = "
+ ", ".join(f"{tag} {value:+.1f}" for tag, value in by_tag.items())
)
return contributions
Model B (48 batches): R2 per component = 0.333, 0.133, 0.085
Model B: largest |t2| = [37, 48, 44, 46, 16, 29]; largest |t3| = [45, 39, 46, 47, 43, 14]
Batch 39: t3 contributions per tag = Flow-1 +0.5, Flow-2 +3.5, Press-1 +0.8, Press-2 +2.3, Press-3 +6.5,
TempC-1 +4.9, TempH-1 +1.7, TempR-1 -0.1, TempR-2 +0.9, TempR-3 +0.2
With the extreme batches gone, a second group separates on the
\(t_2\) and \(t_3\) plane: batches 37, 39 and 43 to 48. A
contribution is the weighted difference between two points, and either
point can be the average of a group. The group’s mean contribution vector
against the model centre, averaged over the eight members, names
Press-3 and TempC-1 on both components, with the contribution
concentrated in the first 25 samples, so the difference lies in how these
batches were started; the \(t_3\) contributions of batch 39 printed
above point at the same two tags. Not every member is consistent with the
average: TempH-1 takes both signs across the eight. The raw overlay
shows that these batches were run on a slightly different pressure
profile. Their quality was acceptable. They are not bad batches, they were
operated differently, and a model of normal operation should either
contain enough of them to describe that mode or leave them out; the course
notes leave them out.
Model C: the final model, used to verify the unusual batches#
Model C is fitted on the 40 batches that remain once batch 49, batches 50
to 55 and the eight batches of the second group are removed. The 15
left-out batches are then projected onto it (predict_online at the last
sample gives a complete batch’s scores, \(T^2\) and SPE against a model
it was not part of) and compared with the 95% limits of the 40 training
batches.
def fit_model_c(batches: dict) -> BatchPCA:
"""Rebuild once more without the second cluster: the reference model."""
excluded = set(range(SPE_OUTLIER, 56)) | set(DIFFERENT_BUT_ACCEPTABLE)
kept = {batch_id: batch for batch_id, batch in batches.items() if batch_id not in excluded}
model = BatchPCA(n_components=3).fit(kept)
per_component = model.r2_per_component_.to_numpy()
print(f"Model C ({len(kept)} batches): R2 per component = " + ", ".join(f"{value:.3f}" for value in per_component))
return model
def verify_left_out(model: BatchPCA, batches: dict) -> pd.DataFrame:
"""Project the 15 batches left out of model C onto it and compare them with its 95% limits.
The on-line projection at the last sample of a complete batch gives its scores, T2 and
SPE against the centre and scale of the model it was not part of.
"""
left_out = [SPE_OUTLIER, *SCORE_OUTLIERS, *DIFFERENT_BUT_ACCEPTABLE]
projected = {b: model.predict_online(batches[b], upto_k=model.n_timesteps_) for b in left_out}
table = pd.DataFrame(
{b: (float(r.hotellings_t2), float(r.spe)) for b, r in projected.items()}, index=["T2", "SPE"]
).T
t2_limit, spe_limit = model.hotellings_t2_limit(conf_level=CONF_LEVEL), model.spe_limit(conf_level=CONF_LEVEL)
print(
f"Model C: left-out batches above the SPE limit ({spe_limit:.1f}): {sorted(table.index[table['SPE'] > spe_limit])}"
)
print(
f"Model C: left-out batches above the T2 limit ({t2_limit:.2f}): {sorted(table.index[table['T2'] > t2_limit])}"
)
return table
def observability_table(model: BatchPCA) -> pd.DataFrame:
"""T2 and SPE of the poor-quality batches that the trajectories do not reveal, against the limits."""
table = pd.DataFrame(
{
"T2": model.hotellings_t2_.loc[POOR_QUALITY_NOT_VISIBLE].iloc[:, -1],
"T2 limit": model.hotellings_t2_limit(conf_level=CONF_LEVEL),
"SPE": model.spe_.loc[POOR_QUALITY_NOT_VISIBLE].iloc[:, -1],
"SPE limit": model.spe_limit(conf_level=CONF_LEVEL),
}
)
inside = bool((table["T2"] < table["T2 limit"]).all() and (table["SPE"] < table["SPE limit"]).all())
print(f"Model C: batches {POOR_QUALITY_NOT_VISIBLE} inside both limits: {inside}")
return table
Model C (40 batches): R2 per component = 0.375, 0.114, 0.064
Model C: left-out batches above the SPE limit (24.2): [37, 39, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55]
Model C: left-out batches above the T2 limit (9.27): [37, 50, 51, 52, 53, 54, 55]
Model C: batches [38, 40, 41, 42] inside both limits: True
Every one of the 15 lies above the SPE limit, and the six score outliers
and batch 37 above the \(T^2\) limit as well: the model built without
them flags them. Batches 38, 40, 41 and 42, known to have had poor quality
and kept in the training set, sit inside both limits: nothing in the ten
trajectories distinguishes them from the good batches. This is the lesson
the case study is built around. A model can only detect what the
measurements contain; if the cause of poor quality leaves no trace in the
recorded variables, no amount of modelling will find it, and the fix is to
measure something else. Nomikos and MacGregor (1995) also left these four
batches out of their reference set, on the principle that it should hold
only batches with acceptable operation and acceptable product, and built
their monitoring model on the remaining 36 batches with three components;
they are kept in model C here so that the check can be made. The SBR
case-study page runs the same check sample by sample with
BatchMonitor.
Running the script#
"""Run the whole case study and write its figures."""
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--output-dir", type=pathlib.Path, default=pathlib.Path("case-study-output/dupont"))
parser.add_argument("--data-url", default=None, help="override the openmv.net URL (for example a file:// copy)")
args = parser.parse_args(argv)
args.output_dir.mkdir(parents=True, exist_ok=True)
def save(fig: go.Figure, name: str) -> None:
fig.write_html(args.output_dir / f"{name}.html", include_plotlyjs="cdn")
batches = load_data(args.data_url)
save(plot_raw(batches, "TempC-1", SCORE_OUTLIERS), "raw-TempC-1-score-outliers")
save(plot_raw(batches, "Press-1", SCORE_OUTLIERS), "raw-Press-1-score-outliers")
model_a = fit_model_a(batches)
save(model_a.score_plot(settings=LABELS), "model-a-scores")
save(model_a.spe_plot(settings=LABELS), "model-a-spe")
save(plot_raw(batches, "Flow-1", [SPE_OUTLIER]), "raw-Flow-1-batch-49")
save(plot_raw(batches, "TempC-1", [SPE_OUTLIER]), "raw-TempC-1-batch-49")
spe_share, _by_tag, by_time = diagnose_spe_outlier(model_a, batches)
save(unfolded_contribution_plot(spe_share, SPE_OUTLIER), "spe-contributions-49")
save(unfolded_contribution_plot(spe_share, SPE_OUTLIER, by_tag=True), "spe-contributions-49-by-tag")
save(plot_share_over_time(by_time, SPE_OUTLIER), "spe-contributions-49-by-time")
save(time_varying_loading_plot(model_a, component=1), "model-a-loadings-p1")
contributions = diagnose_score_outliers(model_a, batches)
save(unfolded_contribution_plot(contributions["t1"], 54), "score-contributions-54-t1")
save(plot_raw(batches, "Press-2", [54]), "raw-Press-2-batch-54")
save(unfolded_contribution_plot(contributions["t2"], 55), "score-contributions-55-t2")
model_b = fit_model_b(batches)
save(model_b.score_plot(pc_horiz=2, pc_vert=3, settings=LABELS), "model-b-scores-t2-t3")
save(
unfolded_contribution_plot(diagnose_different_batch(model_b, batches), 39, by_tag=True),
"score-contributions-39-t3",
)
save(plot_raw(batches, "Press-3", [39]), "raw-Press-3-batch-39")
model_c = fit_model_c(batches)
save(model_c.score_plot(settings=LABELS), "model-c-scores")
verify_left_out(model_c, batches)
observability_table(model_c)
print(f"figures written to {args.output_dir}")
return 0
References#
Paul Nomikos and John F. MacGregor, “Multivariate SPC charts for monitoring batch processes”, Technometrics, 37, 41-59, 1995, https://literature.learnche.org/item/34/multivariate-spc-charts-for-monitoring-batch-processes
Paul Nomikos and John F. MacGregor, “Monitoring batch processes using multiway principal component analysis”, AIChE Journal, 40, 1361-1375, 1994, https://literature.learnche.org/item/30/monitoring-batch-processes-using-multiway-principal-component-analysis
Kevin Dunn, Latent Variable Methods course notes (ConnectMV, 2011-2012), the DuPont nylon example, CC BY-SA 3.0.