Descriptive panel data: validate, check the panel, relate to the product#
A descriptive panel study asks a group of assessors (panelists) to score a set of products on a set of attributes, usually with replicates and sometimes across several sessions. The recurring question is: which product characteristics drive the attribute scores, and can we trust the panel that produced them?
The process_improve.sensory subpackage answers that with a small,
generic pipeline. The data is described only as panelist, product, attribute,
replicate, and score; nothing about the pipeline is specific to any particular
kind of product. The flow is:
reshape a raw wide export into the long schema, if the data does not already arrive in it;
validate the panel data and a product-covariate table;
check the panel, then either correct each panelist’s scale use (the Mixed Assessor Model) or drop anomalous panelists;
relate each attribute back to the product, and discriminate the descriptors that carry out-of-sample predictive signal from those that only correlate in this one sample.
The worked example near the end runs all of these on a synthetic dataset.
The data contract#
Panel data is supplied in the descriptive_long schema, one row per score:
Column |
Meaning |
|---|---|
|
Who gave the score. |
|
Which sitting the score came from. |
|
Which product was scored. |
|
Which attribute was scored. |
|
Which repeat of that product/attribute for that panelist. |
|
The numeric rating. |
Alongside it you supply a product-covariate table: one row per product, describing what each product is. This table comes in two flavours, and the distinction decides how the relate step works.
Designed versus observational#
The covariate table is one of two kinds, and you must say which with the
mode argument:
Observational (
mode="observational") - implemented. You did not set the formulation (for example the products are existing market products), but you measured each one, for example by chemical or instrumental analysis. These measured descriptors are correlated covariates, not a designed matrix, so the relate step reports association, not causation: the attribute block is related to the descriptors with PLS, and per-descriptor correlations show which descriptors track which attributes.Designed (
mode="designed") - planned, not implemented yet. You controlled the formulation, so each product is an experimental run and the covariates are the design factors. Because the factors were deliberately varied, the relate step will speak of effects: each attribute regressed on the factors, so a coefficient estimates how changing a factor changes the attribute. For nowmode="designed"raisesNotImplementedError.
Keep the interpretation in mind: an observational analysis supports only “this descriptor is associated with this attribute”, whereas the future designed analysis will support “increasing this factor raises this attribute”.
A related but separate question does not need the covariate table at all: when
the products are the controlled treatments (for example five formulations, or
a formulation crossed with an aging condition) and you simply want to know which
treatments differ on each attribute, use the direct factorial-comparison API in
compare_products() (see
Comparing designed treatments: ANOVA, Tukey HSD and Dunnett below). That path is implemented now; the
mode="designed" relate switch above is the still-planned integration of the
same idea into the covariate-driven pipeline.
Step 0: get the data into long form#
Panel data usually arrives wide (one column per attribute) rather than in the
long schema. reshape_to_long() turns a parsed
table into descriptive_long from an explicit column mapping, melting the
attribute columns when needed. It verifies round-trip invariants (the grand
mean, the mean per attribute, the mean per panelist, and the cell count are
identical before and after), so a wrong mapping fails loudly instead of
corrupting the analysis silently.
from process_improve.sensory import reshape_to_long
# wide_table: rows are assessor x sample x replicate, one column per attribute.
long_df, checks = reshape_to_long(
wide_table,
layout="wide_by_attribute",
mapping={"panelist_id": "Assessor", "product": "Sample", "replicate": "Rep"},
)
assert checks["ok"] # grand / per-attribute / per-panelist means preserved
Already-long data passes through with layout="long" and an attribute /
score mapping. Means-only tables (no panelist column) are refused, since the
Mixed Assessor Model needs panelist-level scores.
Step 1: validate#
validate_descriptive() coerces the inputs to the
schema and checks them: required columns and dtypes, the score range, panel
balance (the fraction of the full panelist x product x attribute x replicate
grid that is missing), label encoding, and mode-specific covariate checks. It
returns a result whose ok flag gates the rest of the pipeline.
import pandas as pd
from process_improve.sensory import validate_descriptive, analyze_descriptive
# panel: a DataFrame in the descriptive_long schema (loaded from your data).
# descriptors: one row per product, with the measured (e.g. instrumental)
# covariates for each product.
descriptors = pd.DataFrame(
{"product": ["A", "B", "C", "D"], "sodium": [0.2, 0.5, 0.8, 1.0], "fat": [3.1, 3.0, 2.9, 3.2]}
)
validated = validate_descriptive(panel, descriptors, mode="observational", score_min=0, score_max=10)
print(validated.ok, validated.warnings)
Step 2 and 3: check the panel and relate#
analyze_descriptive() runs the rest. It first
builds a per-panelist scorecard
(panel_scorecard()) rating each panelist on
discrimination (do they separate the products), agreement (do they rank
products like the rest of the panel), scale use, and drift across sessions. A
panelist is flagged only when it is both an outlier and genuinely poor on
agreement or discrimination. Passing drop_panelists="auto" removes the
flagged panelists before relating, so a noisy panelist does not contaminate the
product conclusions.
result = analyze_descriptive(validated, drop_panelists="auto")
print(result.panel.flagged) # panelists flagged as anomalous
print(result.dropped) # panelists actually removed
# Observational relate: which descriptors are associated with which attributes.
drivers = pd.DataFrame(result.relate["vip"]) # PLS descriptor importance
assoc = pd.DataFrame(result.relate["associations"]) # attribute-descriptor links
print(assoc[assoc["significant"]])
The observational relate output is:
result.relate["vip"]ranks descriptors by their PLS variable-importance.result.relate["associations"]gives the per-(attribute, descriptor) correlation with a raw p-value, a Benjamini-Hochbergq_valueacross the whole family of tests, and asignificantflag.result.relate["discriminator"]adds the out-of-sample evidence described in the next section.
The result also carries supporting context: result.product_means (each
product-by-attribute mean with a confidence interval) and result.pca (a PCA
sensory map of the products over the attributes).
The designed-mode relate (factor effects rather than associations) is planned;
mode="designed" raises NotImplementedError for now.
Telling genuine drivers from proxies: the discriminator#
The associations table reports marginal correlations: each
attribute-descriptor pair on its own. A pair can be significant for three
different reasons, which a marginal correlation cannot tell apart: the
descriptor genuinely drives the attribute; the descriptor only rides on a
genuine driver (a proxy); or the descriptor happens to line up with the
attribute in this particular set of products (a coincidence). The
discriminator adds out-of-sample evidence to separate these:
a per-attribute cross-validated \(Q^2\) (leave-one-out): is the attribute predictable from the descriptor block at all, on products the model did not see?
result.relate["discriminator"]["per_attribute"]reportsq2_cvand apredictableflag. A coincidence does not predict held-out products, so its attribute often fails this gate.a selectivity ratio per descriptor, computed on the target-projected predictive direction (the single PLS component aligned with the attribute). The selectivity ratio is the share of a descriptor’s variance that the predictive direction explains; unlike the marginal correlation it is judged given the other descriptors, so a descriptor that adds nothing beyond the real drivers scores low. Significance is assessed with a permutation test that controls for testing many descriptors at once (a max-statistic null), gated on the attribute being predictable.
result.relate["discriminator"]["descriptors"]reportsselectivity_ratio, a permutationq_valueand adiscriminator_significantflag.a collinear cluster id per descriptor (
cluster_id): descriptors whose absolute correlation exceeds a threshold are grouped. Two descriptors in the same cluster carry the same information, so they predict equally well and the discriminator keeps them both. This is the honest limit of an observational analysis: it can report that a group of descriptors is predictive, but it cannot say which one inside a collinear cluster is the cause. Separating them needs an external dataset that breaks the collinearity, a designed experiment, or mechanistic knowledge.
So the discriminator demotes coincidences (they fail the gate or the selectivity-ratio test) and groups proxies with their driver, but it does not, and cannot, rank descriptors within a collinear cluster.
Under the hood, the two predictive-importance steps are the standalone
diagnostics target_projection() and
selectivity_ratio() (also available as PLS
methods). Target projection rotates the fitted PLS so that one component lies
along the regression vector \(b\) for the chosen attribute: the weight is
\(w_{\text{TP}} = b / \lVert b \rVert\) and the scores are
\(t_{\text{TP}} = X\, w_{\text{TP}}\). The selectivity ratio of descriptor
\(j\) is the ratio of its explained to its residual sum of squares on that
single component,
\(\text{SR}_j = \text{SS}_{\text{explained},j} / \text{SS}_{\text{residual},j}\),
so it is large only for descriptors aligned with the predictive direction. Two
collinear descriptors carry near-identical selectivity ratios, which is why the
clustering step is needed alongside it.
References
Kvalheim, O. M. and Karstang, T. V. (1989). Interpretation of latent-variable regression models. Chemometrics and Intelligent Laboratory Systems, 7(1-2), 39-51. (target projection)
Rajalahti, T. et al. (2009). Biomarker discovery in mass spectral profiles by means of selectivity ratio plot. Chemometrics and Intelligent Laboratory Systems, 95(1), 35-48. (selectivity ratio)
Correcting the panel: the Mixed Assessor Model#
Panelists differ in how they use the scale: some compress it into a narrow
range, some expand it, some sit consistently high or low. The Mixed Assessor
Model (MAM) separates this scale usage from genuine disagreement about the
products. For each attribute it regresses every panelist’s product scores on the
panel consensus; the slope is the panelist’s scaling coefficient beta:
betanear 1: uses the scale like the panel;beta< 1: compresses;beta> 1: expands.
mixed_assessor_model() returns these coefficients
per panelist and attribute, plus two product-effect F-tests: the MAM one (using
the leftover disagreement as the error term) and the classical one (using the
raw interaction). Removing the scale-usage differences from the error makes the
MAM F-test more powerful.
Instead of dropping a panelist who merely scales differently, you can align
the panel: align_scores() rescales every panelist
onto a common scale (a location lever removes their offset, a scale lever
divides by beta), keeping their data while removing the scale-usage
artefact. analyze_descriptive exposes this through correction:
from process_improve.sensory import mixed_assessor_model, align_scores
mam = mixed_assessor_model(validated.normalized_df)
print(mam.scaling.sort_values("beta").head()) # who compresses / expands
print(mam.ftests) # MAM vs classical F per attribute
# Align all panelists onto a common scale, then relate to the product.
result = analyze_descriptive(validated, correction="align")
print(result.correction, result.mam.scaling.head())
Rescaling does not remove genuine disagreement, so a panelist who truly ranks
the products differently is better handled by dropping (drop_panelists or
correction="drop"); align and drop can be combined.
Comparing designed treatments: ANOVA, Tukey HSD and Dunnett#
When the products are controlled treatments rather than measured market samples
(five formulations, a formulation crossed with an aging condition, a process
setting), and the same panelists score every treatment, the design is a
randomized complete block with panelist as the block. The question is which
treatments differ, and by how much, on each attribute.
compare_products() answers it with a per-attribute
factorial ANOVA followed by the matching post-hoc multiple comparisons; the
pieces are also usable on their own
(factorial_anova(),
tukey_hsd(),
dunnett_vs_control()).
Per attribute the model is a Type III factorial ANOVA
Type III sums of squares keep the effects correct when the grid is unbalanced (a panelist who skipped a sample, a missing cell), and the interaction terms test whether one factor’s effect depends on another, for example whether aging shifts some formulations more than others.
from process_improve.sensory import compare_products
# panel: descriptive_long, with extra factor columns "formulation" and "condition".
result = compare_products(
panel,
factors=["formulation", "condition"],
block="panelist_id",
within="condition", # run the post-hoc tests within each condition (simple effects)
control="Control", # the reference level for Dunnett
)
result.anova # Type III ANOVA table, one row per (attribute, source)
result.tukey # all-pairwise Tukey HSD contrasts
result.dunnett # each formulation vs the Control
result.letters # compact-letter display: shared letter => not separable
result.means # per-level mean with a confidence interval
Set within="condition" to run the post-hoc tests as simple effects
separately within each aging condition, which is the right follow-up once the
formulation x condition interaction is significant; leave it None to
compare the primary factor pooled over the others.
Two post-hoc procedures, two questions#
Both procedures control the family-wise error rate (the chance of any false claim across the whole family of comparisons), but over different families:
Tukey HSD compares every pair of treatments. Its critical difference uses the blocked-model error mean square \(\text{MSE}\) and the studentized-range distribution \(q\), so the panelist-block variance is removed from the yardstick:
\[|\bar{y}_i - \bar{y}_j| \;>\; q_{\alpha,\,g,\,\nu}\,\sqrt{\text{MSE}/n}\]where \(g\) is the number of treatments and \(\nu\) the error degrees of freedom. The compact-letter display in
result.letterssummarises it: treatments that share a letter are not separable.Dunnett compares each treatment to one control. Because those comparisons all share the control mean they are correlated, so the critical value comes from the multivariate-t distribution rather than the studentized range. Guarding fewer comparisons makes Dunnett more powerful than Tukey for the treatment-vs-control question, but it cannot compare two treatments to each other.
A rule of thumb: use Dunnett when there is a genuine reference you are benchmarking against (a Control formulation, or REF for the aging axis), and Tukey when the treatments are peers and you want the full pairwise map. Running both is common; report them as answering different questions.
Worked numbers#
Take one attribute scored by five formulations, seven panelists each, with an error mean square of \(\text{MSE}=1.0\) on \(\nu=30\) degrees of freedom. The level means are:
Statistic |
Control |
T1 |
T2 |
T3 |
T4 |
|---|---|---|---|---|---|
mean |
3.0 |
3.3 |
3.5 |
4.5 |
6.2 |
The standard error of a group mean is \(\sqrt{\text{MSE}/n}=\sqrt{1/7}=0.378\) and of a difference is \(\sqrt{2\,\text{MSE}/n}=0.535\).
Tukey. With \(q_{0.05,\,5,\,30}=4.102\), the critical difference is
\(4.102 \times 0.378 = 1.55\). Only the gaps involving T4 exceed it, so the
letter display is T4 = a and Control, T1, T2, T3 = b.
Dunnett. The two-sided critical value for four treatments versus one control at \(\nu=30\) is about \(2.18\), giving a critical difference of \(2.18 \times 0.535 = 1.16\).
The two cutoffs disagree on exactly one comparison:
Comparison |
Difference |
Tukey (cutoff 1.55) |
Dunnett (cutoff 1.16) |
|---|---|---|---|
T1 - Control |
0.30 |
not significant |
not significant |
T2 - Control |
0.50 |
not significant |
not significant |
T3 - Control |
1.50 |
not significant |
significant (p=0.03) |
T4 - Control |
3.20 |
significant |
significant |
T3 sits 1.50 above the Control. Tukey, which must protect all ten pairwise comparisons, raises its bar to 1.55 and calls it non-significant; Dunnett, which protects only the four treatment-vs-control comparisons, has a lower bar (1.16) and flags it. Same data, both correct: the procedures answer different questions with correctly calibrated thresholds.
Post-hoc references
Tukey, J. W. (1949). Comparing individual means in the analysis of variance. Biometrics, 5(2), 99-114.
Dunnett, C. W. (1955). A multiple comparison procedure for comparing several treatments with a control. Journal of the American Statistical Association, 50(272), 1096-1121.
Piepho, H.-P. (2004). An algorithm for a letter-based representation of all-pairwise comparisons. Journal of Computational and Graphical Statistics, 13(2), 456-466.
Worked example#
This example runs the whole pipeline on a small synthetic panel. Ten assessors
(J01-J10) scored eighteen products (Product A-R) on nine attributes (Aroma
intensity, Sweetness, Sourness, Bitterness, Firmness, Juiciness, Colour
intensity, Aftertaste, Liking), as integers on a 0-10 scale. The scores came out
of the scoring software in the wide layout, with an extra site column we do
not need and a few missing cells. We also have instrumental measurements per
product, in realistic physical units, split on purpose into three kinds:
genuine mechanistic correlates:
brixfor sweetness,titratable_acidityfor sourness,polyphenolsfor bitterness,aroma_oavfor aroma,viscosityfor firmness;proxies that ride on a genuine driver:
refractive_indexandspecific_gravityrise with dissolved sugar (brix);conductivityrides on the acid ions;total_dissolved_solidsis an aggregate;pricetracks Liking through the sample frame;unrelated measurement-condition nuisances with no causal link to any attribute:
serving_temperature,headspace_volume,sample_massandlab_humidity.
Three assessors were constructed to misbehave: J07 scores at random, J03 rates everything high, and J09 uses only the middle of the scale.
Step 1, reshape. The export is wide, so melt the attribute columns to the
long schema and ignore site. The round-trip check confirms the grand,
per-attribute, and per-assessor means are unchanged.
from process_improve.sensory import (
reshape_to_long, validate_descriptive, panel_scorecard,
mixed_assessor_model, analyze_descriptive,
)
long_df, checks = reshape_to_long(
wide_table,
layout="wide_by_attribute",
mapping={"panelist_id": "Assessor", "product": "Product", "ignore": ["site"]},
)
assert checks["ok"]
Step 2, validate.
validated = validate_descriptive(long_df, covariates, mode="observational")
Any out-of-range or missing-cell issues are surfaced as warnings.
Step 3, check the panel. The scorecard flags J07 (low agreement with the
panel: its random scores do not track the consensus). The Mixed Assessor Model
reports each assessor’s scaling
coefficient beta: about 1 for most, well below 1 for the compressor J09, and
a large offset for the high rater J03. Once the random assessor is removed, the
leftover assessor-by-product interaction is mostly scale usage, so the MAM
product F-test exceeds the classical one (the disagreement error term shrinks).
card = panel_scorecard(long_df)
print(card.flagged) # ['J07']
mam = mixed_assessor_model(long_df)
print(mam.scaling.sort_values("beta").head())
Step 4, correct and relate. Align all assessors onto a common scale (J03 is brought down, J09’s range is stretched), then relate the attributes to the measurements.
result = analyze_descriptive(validated, correction="align")
import pandas as pd
assoc = pd.DataFrame(result.relate["associations"])
print(assoc[assoc["significant"]])
The marginal test flags sixteen pairs. The genuine correlates are there
(brix with Sweetness, titratable_acidity with Sourness, price with
Liking), but so are the proxies (refractive_index and specific_gravity
with Sweetness, which ride on brix) and even a coincidence: lab_humidity
correlates with Liking (r about -0.67) purely by chance in these eighteen
products. A within-sample correlation on its own cannot tell these three cases
apart.
Step 5, discriminate. The discriminator adds the out-of-sample evidence.
disc = result.relate["discriminator"]
gate = pd.DataFrame(disc["per_attribute"]) # cross-validated Q-squared per attribute
drivers = pd.DataFrame(disc["descriptors"]) # selectivity ratio, q-value, cluster id
print(drivers[drivers["discriminator_significant"]])
It narrows the sixteen marginal hits to twelve:
The cross-validated \(Q^2\) gate keeps Sweetness (
q2_cvabout 0.95) and Liking (about 0.94) but rejects Juiciness, Colour intensity and Aftertaste, for which no covariate predicts held-out products.lab_humidityis demoted: although it correlates with Liking in-sample, it carries no predictive selectivity, so the permutation test does not keep it, while the genuinebrixandpricefor Liking remain significant. This is the coincidence caught.brix,refractive_indexandspecific_gravityall stay significant for Sweetness and share onecluster_id. The discriminator reports them as a single inseparable group: from these data you cannot say which of the three is the cause, because each carries the same information.
This is the trap, stated precisely: a within-sample correlation is not a transferable, causal link. Cross-validation demotes coincidences, and the selectivity ratio plus clustering groups proxies with their driver, but ranking descriptors within a collinear cluster needs evidence this single panel cannot supply: an external dataset that breaks the collinearity, a designed experiment, or mechanistic knowledge.
The full runnable scenario is in the test suite
(tests/test_sensory_end_to_end.py).
Using the tools from an agent#
The steps are exposed as agent-callable tools (see
process_improve.sensory.tools.get_sensory_tool_specs()), taking the panel
and covariate tables as lists of row-records and returning JSON:
sensory_reshape_to_long- reshape a parsed wide/long table into thedescriptive_longschema with round-trip checks.sensory_validate_descriptive- validate the inputs.sensory_panel_check- panel quality from the panel alone (no covariates): the scorecard with flags, the MAM scaling coefficients and F-tests, and, withalign=true, the rescaled panel.sensory_analyze_descriptive- the full pipeline, with acorrectionoption ("none"/"align"/"drop"), the MAM results, and (unlessdiscriminatoris set false) the cross-validated discriminator in its output.
The analyze tool validates first and refuses to run if validation fails, so an agent cannot skip the gate.